diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 62cee5c..a99aeae 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: - run: pnpm lint - run: pnpm typecheck - run: pnpm test -- --runInBand + - run: pnpm test:jogo - run: pnpm build audit: diff --git a/.gitignore b/.gitignore index 106006f..a2a1a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,5 @@ next-env.d.ts /3d/out/ /3d/__pycache__/ +/3d/fontes/ +/3d/ferramentas/ diff --git a/3d/FERRAMENTAS.md b/3d/FERRAMENTAS.md new file mode 100644 index 0000000..a9e7063 --- /dev/null +++ b/3d/FERRAMENTAS.md @@ -0,0 +1,30 @@ +# Ferramentas do jogo + +Programas baixados ficam em `3d/ferramentas/`, fora do git (`/3d/ferramentas/` no `.gitignore`), instalados sem sudo e sem login. + +| programa | versão | licença (frase lida e arquivo) | como baixar | comando sem janela | +| --- | --- | --- | --- | --- | +| Tiled (com `tmxrasterizer`) | 1.12.2 | GPL-2.0 (editor) e BSD-2-Clause (`libtiled`, `tmxrasterizer`). Em `COPYING` do pacote: "Tiled src/tiled GPL … tmxrasterizer src/tmxrasterizer BSD 2-clause license … The full text of each license is provided in the files LICENSE.GPL and LICENSE.BSD." Arquivo: `/app/share/licenses/org.mapeditor.Tiled/tiled/COPYING` no Flatpak (o mesmo texto em `3d/ferramentas/tiled/squashfs-root/COPYING` no AppImage). | `flatpak install --user -y flathub org.mapeditor.Tiled`. O AppImage (`3d/ferramentas/tiled/`, das [releases](https://github.com/mapeditor/tiled/releases/tag/v1.12.2)) roda, mas só traz o plugin Qt `xcb` e falha com `QT_QPA_PLATFORM=offscreen`; `xvfb-run` não existe aqui. | `env -u DISPLAY -u WAYLAND_DISPLAY flatpak run --env=QT_QPA_PLATFORM=offscreen --command=tiled org.mapeditor.Tiled --export-map json "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.json"` e `env -u DISPLAY -u WAYLAND_DISPLAY flatpak run --env=QT_QPA_PLATFORM=offscreen --command=tmxrasterizer org.mapeditor.Tiled "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.png"` | +| Blender | 5.2.2 LTS (`blender --version`) | GPL. Em `/usr/share/doc/blender/readme.html`: "Blender can be used freely for any purpose, including commercial use and distribution. It's free and open-source software, released under the GNU GPL licence." Bibliotecas de terceiros em `/usr/share/doc/blender/license/license.md`. | Já instalado pelo sistema (pacote `blender` 5.2.2-2 do Manjaro). | `blender -b -P script.py` | +| MPFB (MakeHuman para Blender, extensão) | 2.0.18 (`blender_manifest.toml`; build 20261010) | Código GPL-3.0-or-later, recursos CC0. Em `LICENSE.md` do [repositório](https://github.com/makehumancommunity/mpfb2/blob/master/LICENSE.md): "The MPFB source (as defined per the above) is released under GPLv3." e "These assets have been released under CC0 1.0 Universal."; sobre a saída: "As the assets have been released under CC0, there is no limitation on what you can do with this combined output." O zip da extensão não traz arquivo de licença; o manifesto diz `license = ["SPDX:GPL-3.0-or-later"]`. | `3d/ferramentas/baixados/add-on-mpfb-v2.0.18.zip` de [extensions.blender.org](https://extensions.blender.org/add-ons/mpfb/) (sha256 `c075b3e9…2a2dad`), instalado só na pasta separada: `BLENDER_USER_RESOURCES=$PWD/3d/ferramentas/blender-user blender -b --factory-startup --command extension install-file -r user_default -e 3d/ferramentas/baixados/add-on-mpfb-v2.0.18.zip` | `BLENDER_USER_RESOURCES=$PWD/3d/ferramentas/blender-user blender -b -P 3d/humano.py` (instala os pacotes abaixo na pasta de dados do MPFB se faltarem) | + +## Como mudar o mapa + +1. Abrir `3d/mapa/escritorio.tmx` no Tiled: `flatpak run org.mapeditor.Tiled 3d/mapa/escritorio.tmx`. +2. Editar as camadas `planta`, `moveis` e `marcas` e salvar. +3. Rodar `bash 3d/mapa.sh`: exporta `3d/mapa/escritorio.json` e `escritorio.png`, escreve `lib/jogo/mapas/escritorio.ts` e monta `public/jogo/mapa.glb` no Blender. +4. Rodar `pnpm test -- lib/jogo/mapas`. + +O `.tmx` é a fonte. `bash 3d/mapa.sh --gerar` refaz o `.tmx` pelo `3d/mapa.py` e apaga o que foi editado à mão. + +## Pacotes de recursos do MPFB + +Baixados de `https://files.makehumancommunity.org/asset_packs//_cc0.zip` (o link `files2` da [página](https://static.makehumancommunity.org/assets/assetpacks/index.html) dá o mesmo arquivo, mas a ~10 KB/s) para `3d/ferramentas/baixados/` e extraídos em `3d/ferramentas/blender-user/extensions/.user/user_default/mpfb/data/`. Nenhum zip traz arquivo de licença: a licença lida é o campo `"license"` de cada recurso em `packs/.json` (dentro do zip) e o cabeçalho dos arquivos usados. Há cabeçalhos AGPL em alguns arquivos de pacotes marcados CC0 (ex.: `cortu_cargo_pants/cargo_pants.obj`, `blindsaypatten_uniform_skin_texture.mhmat`); esses ficaram de fora. + +| pacote | sha256 | recursos usados em `3d/humano.py` | licença lida | +| --- | --- | --- | --- | +| makehuman_system_assets | `b542127a…0a0107` | pele `young_caucasian_male`, olhos `low-poly`, sobrancelha `eyebrow001`, cabelo `short02` (a malha base e o esqueleto `mixamo` vêm dentro da extensão, recursos CC0 pelo `LICENSE.md` acima) | CC0 nos 94 itens de `packs/makehuman_system_assets.json`; cabeçalho dos `.mhmat`/`.mhclo`: "This asset was explicitly released as CC0 in september 2020." | +| shirts01 | `a5a723b0…3bcd3` | `namuhekam_male_polo_shirt` (autor namuhekam) | `"license": "CC0"` em `packs/shirts01.json`; CC0 no cabeçalho do `.mhclo`, `.obj` e `.mhmat` | +| pants01 | `e4e0ec60…ad9` | `toigo_wool_pants` (autora MargaretToigo) | `"license": "CC0"` em `packs/pants01.json`; CC0 no cabeçalho do `.mhclo` e `.obj` | +| shoes01 | `ded3f704…5dba9` | `toigo_ankle_boots_male` (autora MargaretToigo) | `"license": "CC0"` em `packs/shoes01.json`; CC0 no cabeçalho do `.mhclo` e `.obj` | +| skins01, hair01 | `7495ab99…4ad9`, `49445d69…40c9` | baixados, não usados | CC0 no `packs/*.json`, mas com cabeçalhos AGPL em 2 e 10 arquivos | diff --git a/3d/humano.py b/3d/humano.py new file mode 100644 index 0000000..a8816b5 --- /dev/null +++ b/3d/humano.py @@ -0,0 +1,333 @@ +"""Prova do MPFB2 (MakeHuman para Blender) por linha de comando: homem adulto magro, vestido, com esqueleto "mixamo". + +Mede triângulos e ossos, passa os 6 clipes do zumbi atual (3d/fontes/zumbi, ossos mixamorig:*) para o esqueleto +novo, exporta 3d/out/jogo/humano.glb e fotografa com a luz de zumbi-frente.png e zumbi-perto.png (3d/jogo.py). + +Rodar SEMPRE com a pasta de usuário separada (a extensão e os pacotes ficam lá, fora do Blender do Evandro): + BLENDER_USER_RESOURCES=$PWD/3d/ferramentas/blender-user blender -b -P 3d/humano.py +A extensão entra uma vez com (ver 3d/FERRAMENTAS.md): + BLENDER_USER_RESOURCES=… blender -b --factory-startup --command extension install-file -r user_default -e +""" + +import hashlib +import os +import sys +import urllib.request + +import bpy +from mathutils import Vector + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from blocos import RAIZ, SAIDA, falhar, ligar_cycles, sair # noqa: E402 + +try: + from bl_ext.user_default.mpfb.services import AssetService, HumanService, LocationService +except ImportError: + falhar("MPFB não carregou: rode com BLENDER_USER_RESOURCES=3d/ferramentas/blender-user e instale a extensão") + +BAIXADOS = os.path.join(RAIZ, "3d", "ferramentas", "baixados") +CLIPES_FBX = os.path.join(RAIZ, "3d", "fontes", "zumbi") # baixados e extraídos por 3d/jogo.py +RENDERS = os.path.join(SAIDA, "jogo") +GLB = os.path.join(RENDERS, "humano.glb") + +# pacote -> sha256 do zip (só pacotes CC0; https://static.makehumancommunity.org/assets/assetpacks/index.html) +PACOTES = { + "makehuman_system_assets": "b542127a8e25547c7c29c19f2d1d2adb9a664c80396ecd694095dbc8028a0107", + "shirts01": "a5a723b0e84a109bb190fcfeac7f1de4138d875da3e30fe5b3340eac9f38bcd3", + "pants01": "e4e0ec60db34f279be291a83cfd7b342a7c5cf09bb7676682a5f39f4f6ac4ad9", + "shoes01": "ded3f70428505eabbf1f6d7b5f61196a7366ef20757103d276ad0ed336c35ada", +} +# files2 (o link da página) entrega a ~10 KB/s; files é o mesmo conteúdo (mesmo tamanho e sha256) +URL_PACOTE = "https://files.makehumancommunity.org/asset_packs/{0}/{0}_cc0.zip" + +# homem adulto (25 anos), magro, altura média; a raça fica no padrão do MPFB (1/3 de cada) +CORPO = {"gender": 1.0, "age": 0.5, "muscle": 0.5, "weight": 0.3, "proportions": 0.5, "height": 0.5, + "cupsize": 0.5, "firmness": 0.5, "race": {"asian": 0.33, "caucasian": 0.33, "african": 0.33}} +PELE = "skins/young_caucasian_male/young_caucasian_male.mhmat" +# (tipo do MPFB, arquivo na pasta de dados); escolhidos com cabeçalho CC0 nos .mhclo e .obj (cargo_pants.obj é AGPL) +PECAS = [ + ("Eyes", "eyes/low-poly/low-poly.mhclo"), + ("Eyebrows", "eyebrows/eyebrow001/eyebrow001.mhclo"), + ("Hair", "hair/short02/short02.mhclo"), + ("Clothes", "clothes/namuhekam_male_polo_shirt/namuhekam_male_polo_shirt.mhclo"), + ("Clothes", "clothes/toigo_wool_pants/toigo_wool_pants.mhclo"), + ("Clothes", "clothes/toigo_ankle_boots_male/toigo_ankle_boots_male.mhclo"), # o mj_cloth_shoes é sapatilha +] +ESQUELETO = "mixamo" +MAX_TRIS = 12_000 +QUADRIL = "mixamorig:Hips" +PERNA = ["UpLeg", "Leg", "Foot"] +CLIPES = { + "parado": "Zombie Idle.fbx", + "andar": "Zombie Walk.fbx", + "atacar": "Zombie Attack.fbx", + "morrer": "Zombie Dying.fbx", + "correr": "Zombie Running.fbx", + "reacao": "Zombie Reaction Hit.fbx", +} + + +def sha256(caminho): + h = hashlib.sha256() + with open(caminho, "rb") as f: + for bloco in iter(lambda: f.read(1 << 20), b""): + h.update(bloco) + return h.hexdigest() + + +def instalar_pacotes(): + dados = LocationService.get_user_data() + for nome, soma in PACOTES.items(): + if os.path.exists(os.path.join(dados, "packs", nome + ".json")): + continue + zip_ = os.path.join(BAIXADOS, nome + "_cc0.zip") + if not os.path.exists(zip_): + os.makedirs(BAIXADOS, exist_ok=True) + urllib.request.urlretrieve(URL_PACOTE.format(nome), zip_) + if sha256(zip_) != soma: + falhar(f"{zip_}: sha256 não confere") + erro = AssetService.fix_and_extract_asset_pack_zip(zip_, dados) + if erro: + falhar(f"{nome}: {erro}") + print(f"pacote {nome} instalado em {dados}", flush=True) + AssetService.update_all_asset_lists() + return dados + + +def triangulos(obj): + malha = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh() + malha.calc_loop_triangles() + n = len(malha.loop_triangles) + obj.to_mesh_clear() + return n + + +def criar(dados): + for obj in list(bpy.data.objects): + bpy.data.objects.remove(obj) + corpo = HumanService.create_human(macro_detail_dict=CORPO) + esqueleto = HumanService.add_builtin_rig(corpo, ESQUELETO) + HumanService.set_character_skin(os.path.join(dados, PELE), corpo, skin_type="MAKESKIN", material_instances=False) + pecas = [] + for tipo, arquivo in PECAS: + caminho = os.path.join(dados, arquivo) + if not os.path.exists(caminho): + falhar(f"falta {caminho}") + pecas.append(HumanService.add_mhclo_asset(caminho, corpo, asset_type=tipo, subdiv_levels=0)) + return esqueleto, corpo, pecas + + +def aplicar_modificadores(obj): + """Grava na malha as máscaras (ajudantes e faces do corpo sob a roupa) e tira subdivisões; só fica o Armature.""" + bpy.context.view_layer.objects.active = obj + if obj.data.shape_keys: + obj.shape_key_add(name="_mistura", from_mix=True) + for chave in list(obj.data.shape_keys.key_blocks)[:-1]: + obj.shape_key_remove(chave) + obj.shape_key_remove(obj.data.shape_keys.key_blocks[0]) + for mod in list(obj.modifiers): + if mod.type == "ARMATURE": + continue + if mod.type == "SUBSURF": + obj.modifiers.remove(mod) + continue + with bpy.context.temp_override(object=obj, active_object=obj): + bpy.ops.object.modifier_apply(modifier=mod.name) + + +def reduzir(malhas, alvo): + """Colapso de arestas com a mesma razão em todas as malhas (antes do Armature: pesos interpolados).""" + total = sum(triangulos(m) for m in malhas) + razao = alvo / total + for _ in range(4): + for m in malhas: + mod = m.modifiers.new("reduzir", "DECIMATE") + mod.ratio = min(1.0, razao) + mod.use_collapse_triangulate = True + m.modifiers.move(len(m.modifiers) - 1, 0) + with bpy.context.temp_override(object=m, active_object=m): + bpy.ops.object.modifier_apply(modifier=mod.name) + total = sum(triangulos(m) for m in malhas) + if total <= MAX_TRIS: + return total + razao = alvo / total + falhar(f"redução parou em {total} triângulos") + + +def comprimento_perna(esqueleto): + ossos = esqueleto.pose.bones + juntas = [esqueleto.matrix_world @ ossos["mixamorig:Left" + n].head for n in PERNA] + return sum((b - a).length for a, b in zip(juntas, juntas[1:])) + + +def contato(esqueleto): + ossos = esqueleto.pose.bones + pes = [f"mixamorig:{lado}{osso}" for lado in ("Left", "Right") for osso in ("Foot", "ToeBase")] + return min((esqueleto.matrix_world @ ossos[n].head).z - ossos[n].bone.head_local.z for n in pes) + + +def quadro_final(acao): + fim = int(acao.frame_range[0]) + for curva in acao.layers[0].strips[0].channelbags[0].fcurves: + pontos = curva.keyframe_points + for anterior, ponto in zip(pontos, pontos[1:]): + if abs(ponto.co[1] - anterior.co[1]) > 1e-4: + fim = max(fim, round(ponto.co[0])) + return fim + + +def alinhar_rolagem(esqueleto, fonte): + """Gira cada osso do MPFB no próprio eixo até o Z dele apontar como o do osso do Mixamo no repouso. + + A cópia de rotação é em espaço de mundo: com a rolagem igual, o osso novo fica com a direção e a torção do + osso do clipe, mesmo com o repouso diferente (MPFB em A, Mixamo em T). A malha não muda: só o eixo do osso gira. + """ + bpy.context.view_layer.objects.active = esqueleto + bpy.ops.object.mode_set(mode="EDIT") + para_local = esqueleto.matrix_world.inverted().to_3x3() + for osso in esqueleto.data.edit_bones: + if osso.name in fonte.data.bones: + z = (fonte.matrix_world.to_3x3() @ fonte.data.bones[osso.name].matrix_local.to_3x3()).col[2] + osso.align_roll(para_local @ z) + bpy.ops.object.mode_set(mode="OBJECT") + + +def transferir(esqueleto, nome_clipe, arquivo, alinhar): + """Como em 3d/jogo.py: rotação de mundo de todos os ossos e posição só do quadril, escalada pela perna e com o pé + de apoio no chão no primeiro quadro; assa a ação.""" + antes = set(bpy.data.objects) + bpy.ops.import_scene.fbx(filepath=os.path.join(CLIPES_FBX, arquivo)) + novos = [o for o in bpy.data.objects if o not in antes] + fonte = next(o for o in novos if o.type == "ARMATURE") + if alinhar: + alinhar_rolagem(esqueleto, fonte) + acao_fonte = fonte.animation_data.action + inicio, fim = int(acao_fonte.frame_range[0]), quadro_final(acao_fonte) + bpy.context.scene.frame_set(inicio) + fonte.scale *= comprimento_perna(esqueleto) / comprimento_perna(fonte) + sem_par = [] + for pb in esqueleto.pose.bones: + if pb.name not in fonte.pose.bones: + sem_par.append(pb.name) + continue + c = pb.constraints.new("COPY_ROTATION") + c.target, c.subtarget = fonte, pb.name + if pb.name == QUADRIL: + c = pb.constraints.new("COPY_LOCATION") + c.target, c.subtarget = fonte, pb.name + if sem_par: + falhar(f"ossos do MPFB sem par no clipe: {sem_par}") + bpy.context.scene.frame_set(inicio) + fonte.location.z -= contato(esqueleto) + bpy.context.view_layer.objects.active = esqueleto + bpy.ops.object.mode_set(mode="POSE") + bpy.ops.pose.select_all(action="SELECT") + bpy.ops.nla.bake(frame_start=inicio, frame_end=fim, only_selected=True, visual_keying=True, + clear_constraints=True, use_current_action=False, bake_types={"POSE"}, + channel_types={"LOCATION", "ROTATION"}) + bpy.ops.object.mode_set(mode="OBJECT") + acao = esqueleto.animation_data.action + acao.name = nome_clipe + acao.use_fake_user = True + curvas = acao.layers[0].strips[0].channelbags[0].fcurves + for c in [c for c in curvas if c.data_path.endswith(".location") and f'["{QUADRIL}"]' not in c.data_path]: + curvas.remove(c) + ossos_fonte = fonte.data.bones.keys() + for o in novos: + bpy.data.objects.remove(o) + bpy.data.actions.remove(acao_fonte) + return ossos_fonte, inicio, fim + + +def exportar(esqueleto, malhas): + os.makedirs(RENDERS, exist_ok=True) + bpy.ops.object.select_all(action="DESELECT") + for o in [esqueleto, *malhas]: + o.select_set(True) + esqueleto.animation_data.action = None + for acao in [a for a in bpy.data.actions if a.name not in CLIPES]: + bpy.data.actions.remove(acao) + bpy.context.scene.frame_set(1) + bpy.ops.export_scene.gltf(filepath=GLB, export_format="GLB", use_selection=True, export_yup=True, + export_apply=False, export_cameras=False, export_lights=False, export_morph=False, + export_tangents=False, export_image_format="JPEG", export_jpeg_quality=80, + export_animations=True, export_animation_mode="ACTIONS", + export_anim_slide_to_zero=True, export_force_sampling=False, + export_optimize_animation_size=True) + + +def renderizar(): + """Reabre o GLB e fotografa com a luz de renderizar() em 3d/jogo.py: parado no quadro 30 (frente e perto) e + andando no meio do clipe.""" + bpy.ops.wm.read_homefile(use_empty=True) + cena = bpy.context.scene + bpy.ops.import_scene.gltf(filepath=GLB) + esqueleto = next(o for o in cena.objects if o.type == "ARMATURE") + malhas = [o for o in cena.objects if o.type == "MESH" and o.parent == esqueleto] # sem a esfera do importador + pontos = [o.matrix_world @ v.co for o in malhas for v in o.data.vertices] + pe, topo = min(p.z for p in pontos), max(p.z for p in pontos) + mundo = bpy.data.worlds.new("neutro") + mundo.use_nodes = True + mundo.node_tree.nodes["Background"].inputs["Color"].default_value = (0.5, 0.5, 0.5, 1) + mundo.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.6 + cena.world = mundo + luz = bpy.data.objects.new("principal", bpy.data.lights.new("principal", "AREA")) + luz.data.energy, luz.data.size = 300, 2 + luz.location = (1.5, -3, 3) + luz.rotation_euler = (Vector((0, 0, 1.2)) - luz.location).to_track_quat("-Z", "Y").to_euler() + cena.collection.objects.link(luz) + camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera")) + cena.collection.objects.link(camera) + cena.camera = camera + ligar_cycles(cena) + acoes = {a.name.split("_")[0]: a for a in bpy.data.actions} + for nome, clipe, quadro, alvo, distancia in (("frente", "parado", 30, 0.9, 3.2), ("perto", "parado", 30, 1.4, 1.5), + ("andando", "andar", None, 0.9, 3.2)): + acao = next(a for n, a in acoes.items() if n.startswith(clipe)) + esqueleto.animation_data.action = acao + cena.frame_set(quadro or int(sum(acao.frame_range) / 2)) + camera.location = (0.3 * distancia / 4, -distancia, alvo + 0.1) + camera.rotation_euler = (Vector((0, 0, alvo)) - camera.location).to_track_quat("-Z", "Y").to_euler() + sair(cena, os.path.join(RENDERS, f"humano-{nome}.png"), (800, 800), "PNG", "RGB", 64, False) + return pe, topo + + +def main(): + dados = instalar_pacotes() + esqueleto, corpo, pecas = criar(dados) + roupas = [p for p, (tipo, _) in zip(pecas, PECAS) if tipo == "Clothes"] + t_corpo_visivel = triangulos(corpo) + for mod in corpo.modifiers: # mede o corpo sem as máscaras de roupa ("Delete" do MPFB) + if mod.type == "MASK" and mod.vertex_group != "body": + mod.show_viewport = False + t_corpo = triangulos(corpo) + for mod in corpo.modifiers: + mod.show_viewport = True + t_roupa = sum(triangulos(r) for r in roupas) + t_resto = sum(triangulos(p) for p in pecas if p not in roupas) + malhas = [corpo, *pecas] + t_total = t_corpo + t_roupa + t_resto + print(f"triângulos: corpo {t_corpo} (sem as faces sob a roupa: {t_corpo_visivel}), roupa {t_roupa}, " + f"olhos+sobrancelha+cabelo {t_resto}, total {t_total}", flush=True) + for m in malhas: + aplicar_modificadores(m) + t_escondido = sum(triangulos(m) for m in malhas) + tris = reduzir(malhas, MAX_TRIS * 0.97) + print(f"triângulos: total sem as faces escondidas {t_escondido}, reduzido {tris}", flush=True) + for m in malhas: + print(f" {m.name}: {triangulos(m)}", flush=True) + ossos = [b.name for b in esqueleto.data.bones] + print(f"ossos ({len(ossos)}): {' '.join(ossos)}", flush=True) + for i, (nome, arquivo) in enumerate(CLIPES.items()): + ossos_clipe, inicio, fim = transferir(esqueleto, nome, arquivo, alinhar=i == 0) + print(f"clipe {nome} <- {arquivo} quadros {inicio}-{fim}; ossos do clipe sem par no MPFB: " + f"{sorted(set(ossos_clipe) - set(ossos))}", flush=True) + exportar(esqueleto, malhas) + print(f"humano.glb {os.path.getsize(GLB)} bytes", flush=True) + pe, topo = renderizar() + print(f"repouso: pés em {pe:.3f} m, altura {topo - pe:.3f} m", flush=True) + print(f"OK humano tris={tris} ossos={len(ossos)}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/3d/itens.sh b/3d/itens.sh new file mode 100644 index 0000000..d2086c1 --- /dev/null +++ b/3d/itens.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Refaz public/jogo/itens.webp (atlas 2x2 de 512x512) a partir de 3d/itens/*.png. +set -euo pipefail +cd "$(dirname "$0")/.." +FUZZ="${FUZZ:-6%}" +Q="${Q:-90}" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +OUT=public/jogo/itens.webp +CONF=/home/evandro/tmp/jogo +mkdir -p "$CONF" + +for n in municao kit cafe bateria; do + # fundo por preenchimento a partir dos 4 cantos (sem limiar global) + magick "3d/itens/$n.png" -alpha set -fuzz "$FUZZ" -fill none \ + -draw "alpha 0,0 floodfill" -draw "alpha 1023,0 floodfill" \ + -draw "alpha 0,1023 floodfill" -draw "alpha 1023,1023 floodfill" \ + -channel A -morphology Erode Disk:1 -blur 0x0.7 +channel \ + -trim +repage \ + -bordercolor none -background none -gravity center \ + -resize 215x215 \ + -extent 256x256 "$TMP/$n.png" +done + +magick -size 512x512 xc:none \ + "$TMP/municao.png" -geometry +0+0 -composite \ + "$TMP/kit.png" -geometry +256+0 -composite \ + "$TMP/cafe.png" -geometry +0+256 -composite \ + "$TMP/bateria.png" -geometry +256+256 -composite \ + -define webp:alpha-quality=100 -define webp:method=6 -quality "$Q" "$OUT" + +magick -size 512x512 xc:"#808080" "$OUT" -composite "$CONF/itens-atlas-cinza.png" +magick -size 512x512 xc:"#d00000" "$OUT" -composite "$CONF/itens-atlas-vermelho.png" +ls -l "$OUT" diff --git a/3d/itens/bateria.png b/3d/itens/bateria.png new file mode 100644 index 0000000..27d6116 Binary files /dev/null and b/3d/itens/bateria.png differ diff --git a/3d/itens/cafe.png b/3d/itens/cafe.png new file mode 100644 index 0000000..c004f08 Binary files /dev/null and b/3d/itens/cafe.png differ diff --git a/3d/itens/kit.png b/3d/itens/kit.png new file mode 100644 index 0000000..405ffb6 Binary files /dev/null and b/3d/itens/kit.png differ diff --git a/3d/itens/municao.png b/3d/itens/municao.png new file mode 100644 index 0000000..5d3186d Binary files /dev/null and b/3d/itens/municao.png differ diff --git a/3d/jogo.py b/3d/jogo.py new file mode 100644 index 0000000..0bb083a --- /dev/null +++ b/3d/jogo.py @@ -0,0 +1,1094 @@ +"""Zumbi do jogo em /jogo: baixa o "Male City Zombie" (Rikindle3D, CC0), reduz e exporta public/jogo/zumbi.glb. + +Rodar da raiz do repositório: blender -b -P 3d/jogo.py +Baixa o .7z para 3d/fontes/ (se faltar), confere o sha256, extrai com o 7z do sistema, reduz a malha para no máximo +12 mil triângulos, repinta pele e roupa por assado com as texturas de 3d/texturas/ (a textura original é uma crosta +igual em tudo), passa as animações do Mixamo para o esqueleto do modelo (no lugar) e exporta o GLB (Y para cima, +metros, pés na origem, de frente para +Z) com a roupa A; as roupas B e C saem em public/jogo/roupa-b.jpg e roupa-c.jpg +(mesmas UVs). O `atacar` é recortado para um golpe só (preparo, impacto, volta, fechando em laço) e o instante do +impacto vai para lib/jogo/golpe.json. Grava public/jogo/CREDITOS.json, a capa da página (public/jogo/capa.jpg) e +renderiza 3d/out/jogo/zumbi-frente.png, zumbi-perto.png, zumbi-variacoes.png e golpe.png para avaliar a aparência. +""" + +import hashlib +import json +import os +import struct +import subprocess +import sys +import tempfile +import urllib.request + +import bmesh +import bpy +import numpy as np +from mathutils import Vector +from mathutils.bvhtree import BVHTree + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from blocos import RAIZ, SAIDA, falhar, ligar_cycles, sair # noqa: E402 + +FONTES = os.path.join(RAIZ, "3d", "fontes") +PACOTE = os.path.join(FONTES, "Male City Zombie.7z") +EXTRAIDO = os.path.join(FONTES, "zumbi") +PUBLICO = os.path.join(RAIZ, "public", "jogo") +GLB = os.path.join(PUBLICO, "zumbi.glb") +CREDITOS = os.path.join(PUBLICO, "CREDITOS.json") +CAPA = os.path.join(PUBLICO, "capa.jpg") +RENDERS = os.path.join(SAIDA, "jogo") +GOLPE = os.path.join(RAIZ, "lib", "jogo", "golpe.json") + +PAGINA = "https://opengameart.org/content/male-city-zombie-ready-for-use-in-game-engines" +URL = "https://opengameart.org/sites/default/files/Male%20City%20Zombie.7z" +SHA256 = "2858c0c4968740b372108afc64ba009ef4d0e0425edbb5dc3dc50d46f4d0a661" + +MAX_TRIS, MAX_BYTES, LADO_TEXTURA, QUALIDADE_JPEG = 12_000, 1_500_000, 1024, 80 +TOLERANCIA_CHAVE = 2e-3 # componente de quatérnio (~0,2°) ou metro no quadril +ALTURA = (1.7, 1.9) +OSSO_MODELO, OSSO_ANIM = "CityDeadOutfit:", "mixamorig:" +QUADRIL = OSSO_MODELO + "Hips" +PERNA = ["UpLeg", "Leg", "Foot"] # da coxa ao tornozelo: mede a perna para ajustar a altura do quadril +DURACAO_GOLPE = 1.4 # s: a lógica toca o golpe em 0,8 s; recorte mais longo encurta preparo e volta +MISTURA = (4, 5, 6) # quadros do fim do recorte que voltam à pose do primeiro para o laço fechar +# clipe do jogo <- arquivo do pacote; ficam de fora Walking, Zombie Attack2/3, Zombie Idle2 e Zombie Scream +CLIPES = { + "parado": "Zombie Idle.fbx", + "andar": "Zombie Walk.fbx", + "atacar": "Zombie Attack.fbx", + "morrer": "Zombie Dying.fbx", + "correr": "Zombie Running.fbx", + "reacao": "Zombie Reaction Hit.fbx", +} +MATERIAIS = { # material do FBX -> nome no GLB, aspereza e mapa de normal original (os olhos não têm) + "City_Infected_Man_Body1": ("corpo", 0.7, "InfectedCityMan_City_Infected_Man_Body1_Normal.png"), + "City_Man_Infected_Outfit1": ("roupa", 0.9, "InfectedCityMan_City_Man_Infected_Outfit1_Normal.png"), + "defaultPolygonShader1": ("olhos", 0.4, None), +} +FORCA_NORMAL = 0.3 # o normal original a 1,0 é a "crosta"; fraco, só marca dobras e costuras +TEXTURAS_JOGO = os.path.join(RAIZ, "3d", "texturas") +LADO = {"corpo": LADO_TEXTURA, "roupa": LADO_TEXTURA, "olhos": 256} +PECAS = ["pele", "camisa", "calca", "olho", "dente", "gengiva"] +ESCALA_POSICAO = 0.25 # posição de objeto (|x| < 1, z < 2 m) codificada em 0..1 no assado +ROUPAS = { # cor média da camisa e da calça (sRGB) por variação; a "a" vai no GLB, as outras em JPEG ao lado + "a": ((0.80, 0.77, 0.69), (0.20, 0.20, 0.21)), # branca encardida, chumbo + "b": ((0.62, 0.74, 0.86), (0.55, 0.48, 0.34)), # azul-clara, cáqui + "c": ((0.27, 0.31, 0.21), (0.07, 0.07, 0.075)), # verde-musgo, preta +} +MAX_BYTES_JPEG = 220_000 # roupas B e C e capa +SANGUE_SECO = (0.25, 0.05, 0.035) +COBERTURA_SANGUE = 1 / 3 + + +def sha256(caminho): + h = hashlib.sha256() + with open(caminho, "rb") as f: + for bloco in iter(lambda: f.read(1 << 20), b""): + h.update(bloco) + return h.hexdigest() + + +def obter_fontes(): + os.makedirs(FONTES, exist_ok=True) + if not os.path.exists(PACOTE): + print(f"baixando {URL}", flush=True) + urllib.request.urlretrieve(URL, PACOTE) + if sha256(PACOTE) != SHA256: + falhar(f"sha256 de {PACOTE} não confere; apague o arquivo e rode de novo") + subprocess.run(["7z", "x", "-y", f"-o{EXTRAIDO}", PACOTE], check=True, stdout=subprocess.DEVNULL) + faltando = [n for n in ["InfectedCityMan.fbx", *CLIPES.values()] if not os.path.exists(os.path.join(EXTRAIDO, n))] + if faltando: + falhar(f"faltam no pacote: {faltando}") + + +def importar_fbx(nome): + antes = set(bpy.data.objects) + bpy.ops.import_scene.fbx(filepath=os.path.join(EXTRAIDO, nome)) + return [o for o in bpy.data.objects if o not in antes] + + +def triangulos(obj): + malha = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh() + malha.calc_loop_triangles() + n = len(malha.loop_triangles) + obj.to_mesh_clear() + return n + + +def preparar_modelo(): + novos = importar_fbx("InfectedCityMan.fbx") + for o in [o for o in novos if o.type not in {"ARMATURE", "MESH"}]: # a câmera "bottom" que veio no FBX + bpy.data.objects.remove(o) + esqueleto = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None) + corpo = next((o for o in bpy.data.objects if o.type == "MESH"), None) + if not esqueleto or not corpo or not corpo.vertex_groups: + falhar("o FBX do modelo não trouxe esqueleto e malha com pesos") + for acao in list(bpy.data.actions): # o "Take 001" de 2 quadros do arquivo do modelo + bpy.data.actions.remove(acao) + # escala 0,01 e rotação de 90° do FBX viram transformação aplicada: esqueleto e malha em metros, Z para cima + for o in (esqueleto, corpo): + o.select_set(True) + bpy.context.view_layer.objects.active = esqueleto + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + return esqueleto, corpo + + +def ligar_texturas(corpo): + """Renomeia os materiais, liga o normal original (fraco) e põe o especular no padrão do Principled. + + A cor vem depois, do assado em pintar(). Com especular e tinta padrão o exportador não escreve + KHR_materials_specular, que obrigaria o three.js a usar o material físico (mais pesado no celular). + """ + for slot in corpo.material_slots: + nome, aspereza, normal = MATERIAIS[slot.material.name] + slot.material.name = nome + nos, links = slot.material.node_tree.nodes, slot.material.node_tree.links + bsdf = nos["Principled BSDF"] + for entrada, valor in ( + ("Metallic", 0.0), + ("Roughness", aspereza), + ("IOR", 1.5), + ("Specular IOR Level", 0.5), + ("Specular Tint", (1, 1, 1, 1)), + ("Emission Strength", 0.0), + ): + for link in bsdf.inputs[entrada].links: + links.remove(link) + bsdf.inputs[entrada].default_value = valor + if not normal: + continue + imagem = nos.new("ShaderNodeTexImage") + imagem.image = carregar(normal, "Non-Color") + mapa = next((n for n in nos if n.type == "NORMAL_MAP"), None) or nos.new("ShaderNodeNormalMap") + mapa.inputs["Strength"].default_value = FORCA_NORMAL + links.new(imagem.outputs["Color"], mapa.inputs["Color"]) + links.new(mapa.outputs["Normal"], bsdf.inputs["Normal"]) + + +def carregar(nome, espaco): + imagem = bpy.data.images.load(os.path.join(EXTRAIDO, nome)) + imagem.colorspace_settings.name = espaco + if max(imagem.size) > LADO_TEXTURA: + escala = LADO_TEXTURA / max(imagem.size) + imagem.scale(round(imagem.size[0] * escala), round(imagem.size[1] * escala)) + return imagem + + +def afundar_pele(corpo, folga=0.012): + """Empurra para dentro a pele que fura a roupa (ombros, manga, calça; piora com a redução): todo vértice do corpo + com roupa por cima, num raio de 5 cm ao longo da normal, fica pelo menos `folga` metros abaixo dela. A pele fora + da roupa (raio não acerta) não se mexe, então golas e barras não ganham degrau. + """ + bm = bmesh.new() + bm.from_mesh(corpo.data) + roupa = [s.material.name for s in corpo.material_slots].index("roupa") + so_roupa = bm.copy() + bmesh.ops.delete(so_roupa, geom=[f for f in so_roupa.faces if f.material_index != roupa], context="FACES") + arvore = BVHTree.FromBMesh(so_roupa) + so_roupa.free() + corpo_idx = [s.material.name for s in corpo.material_slots].index("corpo") + movidos = 0 + for v in bm.verts: + if not v.link_faces or v.link_faces[0].material_index != corpo_idx: + continue + inicio = v.co - v.normal * 0.03 # parte de dentro: o vértice que já furou também acha a roupa + acerto = arvore.ray_cast(inicio, v.normal, 0.08)[0] + if acerto is None: + continue + distancia = (acerto - v.co).dot(v.normal) + if distancia < folga: + v.co -= v.normal * (folga - distancia) + movidos += 1 + bm.to_mesh(corpo.data) + bm.free() + return movidos + + +def marcar_pecas(corpo, esqueleto): + """Separa as partes soltas da malha e grava a peça de cada vértice no atributo "peca" ((código + 1) / 8). + + Roupa: camisa e calça são partes soltas (a barra da camisa cobre o cós, então a altura não separa): acima do + quadril é camisa. O material dos olhos também tem dentes e gengivas: olhos são as partes mais altas, gengiva o + que tem fundo (mais de 2 cm em Y), o resto é dente. Devolve as medidas usadas para posicionar sangue e sujeira. + """ + bm = bmesh.new() + bm.from_mesh(corpo.data) + bm.verts.ensure_lookup_table() + nomes = [s.material.name for s in corpo.material_slots] + partes, visto = [], set() + for v in bm.verts: + if v.index in visto or not v.link_faces: + continue + visto.add(v.index) + pilha, parte = [v], [] + while pilha: + u = pilha.pop() + parte.append(u.index) + for aresta in u.link_edges: + w = aresta.other_vert(u) + if w.index not in visto: + visto.add(w.index) + pilha.append(w) + co = np.array([bm.verts[i].co for i in parte]) + partes.append((parte, nomes[v.link_faces[0].material_index], co)) + bm.free() + quadril = esqueleto.data.bones[QUADRIL].head_local.z + topo_olhos = max(co[:, 2].mean() for _, mat, co in partes if mat == "olhos") + codigo = np.zeros(len(corpo.data.vertices)) + geo = {"olhos": [], "boca": [], "camisa": [], "calca": []} + for parte, mat, co in partes: + if mat == "roupa": + peca = "camisa" if co[:, 2].mean() > quadril else "calca" + elif mat == "olhos": + if co[:, 2].mean() > topo_olhos - 0.03: + peca = "olho" + else: + peca = "gengiva" if np.ptp(co[:, 1]) > 0.02 else "dente" + else: + peca = "pele" + codigo[parte] = (PECAS.index(peca) + 1) / 8 + if peca == "olho": + geo["olhos"].append(co.mean(0)) + elif peca in ("dente", "gengiva"): + geo["boca"].append(co.mean(0)) + elif peca in ("camisa", "calca"): + geo[peca].append(co) + atributo = corpo.data.color_attributes.new("peca", "FLOAT_COLOR", "POINT") + atributo.data.foreach_set("color", np.repeat(codigo, 4).astype(np.float32)) + camisa, calca = np.concatenate(geo.pop("camisa")), np.concatenate(geo.pop("calca")) + geo["boca"] = np.mean(geo["boca"], axis=0) + geo["punhos"] = camisa[:, 0].min(), camisa[:, 0].max() # uma manga é curta e a outra vai até o antebraço + geo["barras"] = camisa[:, 2].min(), calca[:, 2].min() + geo["ossos"] = {b.name.removeprefix(OSSO_MODELO): np.array(b.head_local) for b in esqueleto.data.bones} + return geo + + +def assar(corpo): + """Assa em cada material, nas UVs da malha reduzida e na pose de repouso (braços abertos), quatro mapas por texel: + posição e normal de objeto, peça e oclusão (raio de 8 cm: olhos, boca, dedos, golas). Só os texels usados voltam. + """ + cena = bpy.context.scene + ligar_cycles(cena) + deformar = next(m for m in corpo.modifiers if m.type == "ARMATURE") + deformar.show_render = False + bpy.ops.object.select_all(action="DESELECT") + corpo.select_set(True) + bpy.context.view_layer.objects.active = corpo + mapas = {s.material.name: {} for s in corpo.material_slots} + for mapa, amostras in (("peca", 1), ("pos", 1), ("normal", 1), ("ao", 32)): + cena.cycles.samples = amostras + temporarios = [] + for slot in corpo.material_slots: + mat = slot.material + nos, links = mat.node_tree.nodes, mat.node_tree.links + emissao = nos.new("ShaderNodeEmission") + if mapa == "peca": + fonte = nos.new("ShaderNodeAttribute") + fonte.attribute_name = "peca" + saida = fonte.outputs["Color"] + elif mapa == "ao": + fonte = nos.new("ShaderNodeAmbientOcclusion") + fonte.inputs["Distance"].default_value = 0.08 + saida = fonte.outputs["AO"] + else: + geometria = nos.new("ShaderNodeNewGeometry") + fonte = nos.new("ShaderNodeVectorMath") + fonte.operation = "MULTIPLY_ADD" + escala = ESCALA_POSICAO if mapa == "pos" else 0.5 + fonte.inputs[1].default_value, fonte.inputs[2].default_value = (escala,) * 3, (0.5,) * 3 + links.new(geometria.outputs["Position" if mapa == "pos" else "Normal"], fonte.inputs[0]) + saida = fonte.outputs[0] + temporarios.append((mat, geometria)) + links.new(saida, emissao.inputs["Color"]) + final = next(n for n in nos if n.type == "OUTPUT_MATERIAL") + links.new(emissao.outputs["Emission"], final.inputs["Surface"]) + lado = LADO[mat.name] + alvo = nos.new("ShaderNodeTexImage") + alvo.image = bpy.data.images.new(f"{mat.name}-{mapa}", lado, lado, float_buffer=True) + alvo.image.colorspace_settings.name = "Non-Color" + nos.active = alvo + temporarios += [(mat, emissao), (mat, fonte), (mat, alvo)] + bpy.ops.object.bake(type="EMIT", margin=8, use_clear=True) + for mat, no in temporarios: + if no.type == "TEX_IMAGE": + lado = LADO[mat.name] + pixels = np.empty(lado * lado * 4, np.float32) + no.image.pixels.foreach_get(pixels) + mapas[mat.name][mapa] = pixels.reshape(-1, 4)[:, :3] + bpy.data.images.remove(no.image) + mat.node_tree.nodes.remove(no) + for slot in corpo.material_slots: + nos = slot.material.node_tree.nodes + final = next(n for n in nos if n.type == "OUTPUT_MATERIAL") + slot.material.node_tree.links.new(nos["Principled BSDF"].outputs["BSDF"], final.inputs["Surface"]) + deformar.show_render = True + for nome, m in mapas.items(): + m["lado"] = LADO[nome] + codigo = np.rint(m["peca"][:, 0] * 8).astype(int) - 1 # -1: texel fora das ilhas (nem na margem) + m["idx"] = np.flatnonzero(codigo >= 0) + m["peca"] = codigo[m["idx"]] + m["pos"] = (m["pos"][m["idx"]] - 0.5) / ESCALA_POSICAO + n = m["normal"][m["idx"]] * 2 - 1 + m["normal"] = n / np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-6) + m["ao"] = m["ao"][m["idx"], 0] + return mapas + + +def ler_textura(nome, reducao): + """Lê a textura e tira a média de blocos `reducao` × `reducao`: na escala da caixa um texel do mapa cobre uns + 4 pixels da textura, e amostrar sem essa média vira chuvisco (era o aspecto de casca na camisa e na calça).""" + imagem = bpy.data.images.load(os.path.join(TEXTURAS_JOGO, f"{nome}.jpg")) + largura, altura = imagem.size + pixels = np.empty(largura * altura * 4, np.float32) + imagem.pixels.foreach_get(pixels) # imagem de bytes em sRGB: os valores saem como estão no arquivo + bpy.data.images.remove(imagem) + pixels = pixels.reshape(altura // reducao, reducao, largura // reducao, reducao, 4) + return pixels.mean((1, 3))[..., :3] + + +def borrar(a, raio): + for eixo in (0, 1): + for _ in range(2): + a = sum(np.roll(a, k, eixo) for k in range(-raio, raio + 1)) / (2 * raio + 1) + return a + + +def amostrar(tex, u, v): + """Bilinear, repetindo a textura (todas emendáveis).""" + altura, largura = tex.shape[:2] + x, y = u * largura - 0.5, v * altura - 0.5 + x0, y0 = np.floor(x).astype(np.int64), np.floor(y).astype(np.int64) + fx, fy = (x - x0)[:, None], (y - y0)[:, None] + x1, y1 = (x0 + 1) % largura, (y0 + 1) % altura + x0, y0 = x0 % largura, y0 % altura + return (tex[y0, x0] * (1 - fx) + tex[y0, x1] * fx) * (1 - fy) + (tex[y1, x0] * (1 - fx) + tex[y1, x1] * fx) * fy + + +def caixa(tex, m, tamanho, desvio=0.0): + """Projeção de caixa: a textura cobre `tamanho` metros; cada face mistura os três planos pela normal.""" + p, pesos = m["pos"] / tamanho, np.abs(m["normal"]) ** 4 + pesos /= pesos.sum(1, keepdims=True) + 1e-6 + cor = 0 + for eixo, (a, b) in enumerate(((1, 2), (0, 2), (0, 1))): + cor = cor + pesos[:, eixo, None] * amostrar(tex, p[:, a] + desvio + 0.37 * eixo, p[:, b] + 0.61 * desvio) + return cor + + +def capsula(p, a, b, raio): + """Peso 1 no segmento a-b, caindo suave até 0 a `raio` metros dele.""" + a, b = np.asarray(a, float), np.asarray(b, float) + t = np.clip((p - a) @ (b - a) / ((b - a) @ (b - a) + 1e-9), 0, 1) + f = np.clip(1 - np.linalg.norm(p - a - t[:, None] * (b - a), axis=1) / raio, 0, 1) + return f * f * (3 - 2 * f) + + +def luminancia(cor): + return cor @ np.array((0.2126, 0.7152, 0.0722)) + + +def ensanguentar(cor, m, tex, regiao, desvio): + """Sangue seco: a máscara de sangue.jpg só aparece onde a região pesa; no centro dela as manchas se juntam.""" + mancha, borrada, tom = caixa(tex["sangue"], m, 0.4, desvio).T + sangue = np.maximum(mancha * np.clip(regiao * 2, 0, 1), np.clip((borrada * 1.4 + regiao - 1.2) * 4, 0, 1)) + sangue *= regiao > 0.02 + seco = np.outer(0.45 + 0.75 * tom, SANGUE_SECO) * (0.7 + 0.3 * luminancia(cor) / luminancia(cor).mean())[:, None] + return cor + (seco - cor) * (0.95 * sangue)[:, None], sangue + + +def pintar_corpo(m, tex, geo): + p, o = m["pos"], geo["ossos"] + cor = caixa(tex["pele"], m, 0.3) * (0.3 + 0.7 * m["ao"])[:, None] + olheira = sum(capsula(p, c, c, 0.035) for c in geo["olhos"]) + boca = geo["boca"] + (0, -0.025, 0) + labio = capsula(p, boca - (0.025, 0, 0), boca + (0.025, 0, 0), 0.018) + cor *= 1 - (np.clip(olheira, 0, 1) * 0.55 + labio * 0.4)[:, None] * (1, 1.1, 0.95) + regiao = capsula(p, boca, boca - (0, -0.01, 0.07), 0.05) # boca e queixo + regiao = np.maximum(regiao, 0.8 * capsula(p, boca + (0, 0.03, -0.08), boca + (0, 0.05, -0.18), 0.07)) # pescoço + for lado in ("Left", "Right"): + antebraco, mao = o[lado + "ForeArm"], o[lado + "Hand"] + direcao = (mao - antebraco) / np.linalg.norm(mao - antebraco) + regiao = np.maximum(regiao, 0.9 * capsula(p, mao, mao + 0.2 * direcao, 0.07)) + regiao = np.maximum(regiao, 0.6 * capsula(p, antebraco + 0.08 * direcao, mao, 0.06)) + return ensanguentar(cor, m, tex, regiao, 0.0) + + +def pintar_roupa(m, tex, geo, variacao): + p, camisa = m["pos"], (m["peca"] == PECAS.index("camisa")) + tinta_camisa, tinta_calca = (np.array(c) for c in ROUPAS[variacao]) + desvio = {"a": 0.0, "b": 0.31, "c": 0.67}[variacao] + cor = np.where( + camisa[:, None], + caixa(tex["camisa"], m, 0.45, desvio) / tex["camisa_media"] * tinta_camisa, + caixa(tex["calca"], m, 0.3, desvio) / tex["calca_media"] * tinta_calca, + ) + barra_camisa, barra_calca = geo["barras"] + ruido = np.clip(luminancia(caixa(tex["pele"], m, 0.7, 0.5)) / tex["pele_media"], 0.5, 1.5) - 0.5 + sujeira = np.where( + camisa, 0.3 * np.clip(1 - (p[:, 2] - barra_camisa) / 0.1, 0, 1), np.clip(1 - (p[:, 2] - barra_calca) / 0.2, 0, 1) + ) + cor += (np.array((0.24, 0.21, 0.17)) - cor) * (0.7 * sujeira**1.5 * ruido)[:, None] + cor *= (0.5 + 0.5 * m["ao"])[:, None] + esquerdo, direito = ( + np.clip(1 - (geo["punhos"][1] - p[:, 0]) / 0.08, 0, 1) * camisa, + np.clip(1 - (p[:, 0] - geo["punhos"][0]) / 0.08, 0, 1) * camisa, + ) + # metros, modelo em repouso: frente em -Y, peito em z 1,2-1,45, coxa em 0,6-0,85, joelho em 0,45 + if variacao == "a": # escorreu da boca pelo peito; os dois punhos + regiao = np.maximum(capsula(p, (0, -0.13, 1.45), (0.03, -0.13, 1.17), 0.13) * camisa, 0.9 * np.maximum(esquerdo, direito)) + elif variacao == "b": # ombro e lado esquerdos, punho direito, coxa direita + regiao = np.maximum(capsula(p, (0.15, -0.11, 1.42), (0.2, -0.08, 1.22), 0.11) * camisa, 0.9 * direito) + regiao = np.maximum(regiao, 0.8 * capsula(p, (-0.1, -0.11, 0.82), (-0.1, -0.1, 0.62), 0.08) * ~camisa) + else: # barriga, punho esquerdo, joelho esquerdo + regiao = np.maximum(capsula(p, (-0.06, -0.14, 1.13), (0.04, -0.13, 1.03), 0.11) * camisa, 0.9 * esquerdo) + regiao = np.maximum(regiao, 0.8 * capsula(p, (0.09, -0.09, 0.52), (0.09, -0.08, 0.42), 0.08) * ~camisa) + return ensanguentar(cor, m, tex, regiao, desvio + 0.13) + + +def pintar_olhos(m, geo): + """Esclera leitosa e íris apagada (sem pupila preta: olho morto e turvo); dentes amarelados, gengiva escura.""" + p, peca = m["pos"], m["peca"] + cor = np.where((peca == PECAS.index("dente"))[:, None], (0.58, 0.52, 0.38), (0.30, 0.08, 0.07)) + olho = peca == PECAS.index("olho") + cor[olho] = (0.78, 0.76, 0.68) + for c in geo["olhos"]: + d = np.hypot(p[:, 0] - c[0], p[:, 2] - c[2]) + iris = np.clip((0.0075 - d) / 0.0012, 0, 1) * olho * (p[:, 1] < c[1]) + anel = np.clip(1 - np.abs(d - 0.0068) / 0.001, 0, 1) + tom = np.array((0.52, 0.56, 0.56)) - anel[:, None] * 0.14 + cor += (tom - cor) * iris[:, None] + return cor * (0.4 + 0.6 * m["ao"])[:, None] + + +def imagem_nova(nome, m, cor): + lado = m["lado"] + pixels = np.empty((lado * lado, 4), np.float32) + pixels[:, :3], pixels[:, 3] = cor.mean(0), 1 # fora das ilhas: cor lisa, que o JPEG comprime a quase nada + pixels[m["idx"], :3] = np.clip(cor, 0, 1) + imagem = bpy.data.images.new(nome, lado, lado) + imagem.pixels.foreach_set(pixels.ravel()) + return imagem + + +def cobertura(corpo, mapas, sangues): + """Fração da área de cada peça com sangue, sorteando 100 mil pontos por material na superfície (peso pela área).""" + malha = corpo.data + malha.calc_loop_triangles() + n = len(malha.loop_triangles) + lacos, area, material = np.empty(n * 3, np.int32), np.empty(n, np.float32), np.empty(n, np.int32) + malha.loop_triangles.foreach_get("loops", lacos) + malha.loop_triangles.foreach_get("area", area) + malha.loop_triangles.foreach_get("material_index", material) + uv = np.empty(len(malha.loops) * 2, np.float32) + malha.uv_layers.active.data.foreach_get("uv", uv) + uv = uv.reshape(-1, 2)[lacos.reshape(-1, 3)] + sorteio, fracoes = np.random.default_rng(1), {} + for i, slot in enumerate(corpo.material_slots): + nome = slot.material.name + if nome not in sangues: + continue + lado, m = LADO[nome], mapas[nome] + peca, sangue = np.full(lado * lado, -1), np.zeros(lado * lado) + peca[m["idx"]], sangue[m["idx"]] = m["peca"], sangues[nome] + tris = np.flatnonzero(material == i) + tri = sorteio.choice(tris, 100_000, p=area[tris] / area[tris].sum()) + r1, r2 = np.sqrt(sorteio.random(len(tri))), sorteio.random(len(tri)) + ponto = (uv[tri] * np.stack([1 - r1, r1 * (1 - r2), r1 * r2], 1)[:, :, None]).sum(1) + texel = (np.clip(ponto, 0, 0.99999) * lado).astype(int) + texel = texel[:, 1] * lado + texel[:, 0] + for codigo in set(peca[texel]) - {-1}: + fracoes[PECAS[codigo]] = float((sangue[texel][peca[texel] == codigo] > 0.5).mean()) + return fracoes + + +def gravar_jpeg(imagem, caminho): + imagem.file_format = "JPEG" + for qualidade in range(90, 59, -5): + imagem.save(filepath=caminho, quality=qualidade) + if os.path.getsize(caminho) <= MAX_BYTES_JPEG: + return qualidade + falhar(f"{caminho} passa de {MAX_BYTES_JPEG} bytes mesmo com qualidade 60") + + +def pintar(corpo, esqueleto): + """Refaz a cor dos três materiais por assado e grava as roupas B e C (mesmas UVs) em public/jogo/.""" + geo = marcar_pecas(corpo, esqueleto) + mapas = assar(corpo) + corpo.data.color_attributes.remove(corpo.data.color_attributes["peca"]) + tex = {nome: ler_textura(nome, 2 if nome == "sangue" else 4) for nome in ("pele", "camisa", "calca", "sangue")} + for nome in ("pele", "camisa", "calca"): + tex[f"{nome}_media"] = luminancia(tex[nome]).mean() + mancha = np.clip((0.85 - tex["sangue"][..., 1]) / 0.5, 0, 1) # fundo branco: o verde some onde há sangue + borrada = borrar(mancha, 5) + tex["sangue"] = np.stack([mancha, borrada / np.percentile(borrada, 99.5), tex["sangue"][..., 0]], -1) + cores = {"olhos": pintar_olhos(mapas["olhos"], geo)} + cores["corpo"], sangue_corpo = pintar_corpo(mapas["corpo"], tex, geo) + for variacao in ROUPAS: + cor, sangue = pintar_roupa(mapas["roupa"], tex, geo, variacao) + fracoes = cobertura(corpo, mapas, {"corpo": sangue_corpo, "roupa": sangue}) + print(f"sangue na roupa {variacao}: " + ", ".join(f"{k} {v:.0%}" for k, v in fracoes.items()), flush=True) + if max(fracoes.values()) > COBERTURA_SANGUE: + falhar(f"sangue cobre mais de um terço de uma peça na roupa {variacao}: {fracoes}") + if variacao == "a": + cores["roupa"] = cor + else: + caminho = os.path.join(PUBLICO, f"roupa-{variacao}.jpg") + qualidade = gravar_jpeg(imagem_nova(f"roupa-{variacao}", mapas["roupa"], cor), caminho) + print(f"roupa-{variacao}.jpg qualidade {qualidade}, {os.path.getsize(caminho)} bytes", flush=True) + for slot in corpo.material_slots: + nos = slot.material.node_tree.nodes + imagem = nos.new("ShaderNodeTexImage") + imagem.image = imagem_nova(slot.material.name, mapas[slot.material.name], cores[slot.material.name]) + slot.material.node_tree.links.new(imagem.outputs["Color"], nos["Principled BSDF"].inputs["Base Color"]) + + +def reduzir(corpo): + """Colapso de arestas antes do modificador de esqueleto: os pesos dos grupos de vértices são interpolados.""" + esqueleto_mod = next(m for m in corpo.modifiers if m.type == "ARMATURE") + esqueleto_mod.show_viewport = False + origem = triangulos(corpo) + razao = (MAX_TRIS - 50) / origem + for _ in range(8): + mod = corpo.modifiers.new("reduzir", "DECIMATE") + mod.decimate_type, mod.ratio, mod.use_collapse_triangulate = "COLLAPSE", razao, True + corpo.modifiers.move(len(corpo.modifiers) - 1, 0) + n = triangulos(corpo) + if n <= MAX_TRIS: + break + corpo.modifiers.remove(mod) + razao *= 0.99 * MAX_TRIS / n + else: + falhar(f"a redução não chegou a {MAX_TRIS} triângulos") + bpy.context.view_layer.objects.active = corpo + bpy.ops.object.modifier_apply(modifier=mod.name) + esqueleto_mod.show_viewport = True + return origem, n + + +def comprimento_perna(esqueleto, prefixo): + ossos = esqueleto.pose.bones + juntas = [esqueleto.matrix_world @ ossos[prefixo + "Left" + n].head for n in PERNA] + return sum((b - a).length for a, b in zip(juntas, juntas[1:])) + + +def quadro_final(acao): + """Os FBX declaram mais quadros do que animam (o correr anima 25 e declara 122): corta no último que muda.""" + fim = int(acao.frame_range[0]) + for curva in acao.layers[0].strips[0].channelbags[0].fcurves: + pontos = curva.keyframe_points + for anterior, ponto in zip(pontos, pontos[1:]): + if abs(ponto.co[1] - anterior.co[1]) > 1e-4: + fim = max(fim, int(round(ponto.co[0]))) + return fim + + +def contato(esqueleto): + """Altura do pé mais baixo acima da que ele tem no repouso: 0 quando um pé (dedos ou tornozelo) está no chão.""" + pes = [OSSO_MODELO + lado + osso for lado in ("Left", "Right") for osso in ("Foot", "ToeBase")] + ossos = esqueleto.pose.bones + return min((esqueleto.matrix_world @ ossos[n].head).z - ossos[n].bone.head_local.z for n in pes) + + +def transferir(esqueleto, nome_clipe, arquivo): + """Copia a pose do esqueleto do arquivo de animação para o do modelo, quadro a quadro, e assa a ação. + + O FBX de animação não tem malha, então o "repouso" dele é um quadro qualquer, e as proporções são de outro + personagem: a cópia é em espaço de mundo, rotação de todos os ossos e posição só do quadril, com o esqueleto de + origem escalado pela razão entre as pernas e baixado até o pé de apoio tocar o chão no primeiro quadro (todos + começam de pé; sem isso cinco dos seis clipes flutuam ou afundam uns 6 cm). + """ + novos = importar_fbx(arquivo) + fonte = next(o for o in novos if o.type == "ARMATURE") + acao_fonte = fonte.animation_data.action + inicio, fim = int(acao_fonte.frame_range[0]), quadro_final(acao_fonte) + bpy.context.scene.frame_set(inicio) + fonte.scale *= comprimento_perna(esqueleto, OSSO_MODELO) / comprimento_perna(fonte, OSSO_ANIM) + for pb in esqueleto.pose.bones: + alvo = pb.name.replace(OSSO_MODELO, OSSO_ANIM) + if alvo not in fonte.pose.bones: + falhar(f"{arquivo}: falta o osso {alvo}") + c = pb.constraints.new("COPY_ROTATION") + c.target, c.subtarget = fonte, alvo + if pb.name == QUADRIL: + c = pb.constraints.new("COPY_LOCATION") + c.target, c.subtarget = fonte, alvo + bpy.context.scene.frame_set(inicio) + fonte.location.z -= contato(esqueleto) + bpy.context.view_layer.objects.active = esqueleto + bpy.ops.object.mode_set(mode="POSE") + bpy.ops.pose.select_all(action="SELECT") + bpy.ops.nla.bake( + frame_start=inicio, + frame_end=fim, + only_selected=True, + visual_keying=True, + clear_constraints=True, + use_current_action=False, + bake_types={"POSE"}, + channel_types={"LOCATION", "ROTATION"}, + ) + bpy.ops.object.mode_set(mode="OBJECT") + acao = esqueleto.animation_data.action + acao.name = nome_clipe + acao.use_fake_user = True + curvas = acao.layers[0].strips[0].channelbags[0].fcurves + for curva in [c for c in curvas if c.data_path.endswith(".location") and f'["{QUADRIL}"]' not in c.data_path]: + curvas.remove(curva) # só o quadril se desloca; nos outros ossos a posição é a do repouso + for curva in curvas: # um quadro por chave: linear exporta como LINEAR, sem as tangentes do CUBICSPLINE + for ponto in curva.keyframe_points: + ponto.interpolation = "LINEAR" + for o in novos: + bpy.data.objects.remove(o) + bpy.data.actions.remove(acao_fonte) + no_lugar(esqueleto, acao, inicio, fim) + enxugar(curvas) + return inicio, fim + + +def enxugar(curvas): + """Tira as chaves que a interpolação linear entre as vizinhas mantidas já reproduz (canais parados ficam com 2). + + Os canais de um mesmo osso (as 4 do quatérnio, as 3 da posição) perdem as mesmas chaves, como o glTF pede. + """ + grupos = {} + for curva in curvas: + grupos.setdefault(curva.data_path, []).append(curva) + for grupo in grupos.values(): + quadros = [p.co[0] for p in grupo[0].keyframe_points] + valores = [[p.co[1] for p in c.keyframe_points] for c in grupo] + mantidas = [0] + for j in range(2, len(quadros)): + a = mantidas[-1] + for k in range(a + 1, j): + t = (quadros[k] - quadros[a]) / (quadros[j] - quadros[a]) + if any(abs(v[a] + (v[j] - v[a]) * t - v[k]) > TOLERANCIA_CHAVE for v in valores): + mantidas.append(j - 1) + break + mantidas.append(len(quadros) - 1) + fora = sorted(set(range(len(quadros))) - set(mantidas), reverse=True) + for curva in grupo: + for i in fora: + curva.keyframe_points.remove(curva.keyframe_points[i], fast=True) + curva.update() + + +def no_lugar(esqueleto, acao, inicio, fim): + """Prende o quadril na horizontal do primeiro quadro quando o clipe se desloca (só o morrer: cai ~1 m à frente). + + Os clipes em ciclo já começam e terminam no mesmo ponto e o balanço lateral deles fica. + """ + esqueleto.animation_data.action = acao + cena = bpy.context.scene + osso = esqueleto.data.bones[QUADRIL] + posicoes = [] + for f in range(inicio, fim + 1): + cena.frame_set(f) + posicoes.append(esqueleto.pose.bones[QUADRIL].head.copy()) # esqueleto = mundo (transformação aplicada) + deriva = posicoes[-1] - posicoes[0] + if Vector((deriva.x, deriva.y)).length < 0.05: + return + curvas = acao.layers[0].strips[0].channelbags[0].fcurves + loc = [curvas.find(f'pose.bones["{QUADRIL}"].location', index=i) for i in range(3)] + para_local = osso.matrix_local.to_3x3().inverted() # o pai do quadril (root) não se mexe + for f, p in zip(range(inicio, fim + 1), posicoes): + correcao = para_local @ Vector((posicoes[0].x - p.x, posicoes[0].y - p.y, 0)) + for i in range(3): + ponto = next(k for k in loc[i].keyframe_points if int(round(k.co[0])) == f) + ponto.co[1] += correcao[i] + ponto.handle_left[1] += correcao[i] + ponto.handle_right[1] += correcao[i] + for c in loc: + c.update() + + +def posicoes_ossos(esqueleto, acao, inicio, fim): + """Posição de mundo da cabeça de cada osso, quadro a quadro: (quadros, ossos, 3). A frente do zumbi é -Y.""" + esqueleto.animation_data.action = acao + saida = [] + for f in range(inicio, fim + 1): + bpy.context.scene.frame_set(f) + saida.append([esqueleto.matrix_world @ b.head for b in esqueleto.pose.bones]) + return np.array(saida) + + +def misturar(valores, alvo, n): + """Leva os n últimos quadros (eixo 0) até `alvo`, com peso suave (smoothstep): o último fica igual a ele.""" + valores = valores.copy() + for k in range(1, n + 1): + t = k / n + i = len(valores) - 1 - n + k + valores[i] += t * t * (3 - 2 * t) * (alvo[i] - valores[i]) + return valores + + +def tranco(posicoes, n): + """Maior aceleração média dos ossos (m/quadro²) do quadro antes da mistura até o depois da emenda do laço.""" + laco = np.concatenate([posicoes[:-1], posicoes[:3]]) # o último quadro é o primeiro + acel = np.linalg.norm(laco[2:] - 2 * laco[1:-1] + laco[:-2], axis=2).mean(1) + return acel[len(posicoes) - n - 2 :].max() + + +def recortar_golpe(esqueleto, inicio, fim): + """Deixa o `atacar` com um golpe só e grava lib/jogo/golpe.json. + + Extensão de uma mão = distância dela à frente do quadril; cada pico é um impacto e o golpe é o do maior. O golpe + natural vai do último quadro quase parado antes do pico até a volta à pose dele. Mais longo que DURACAO_GOLPE, o + recorte encurta preparo e volta: entre os cortes com o impacto no meio do clipe, fica o de menor tranco na emenda + depois de misturar os últimos quadros de volta ao primeiro. + """ + acao = bpy.data.actions["atacar"] + fps = bpy.context.scene.render.fps + nomes = [b.name for b in esqueleto.pose.bones] + pos = posicoes_ossos(esqueleto, acao, inicio, fim) + quadril = nomes.index(QUADRIL) + ossos = {"esquerda": nomes.index(OSSO_MODELO + "LeftHand"), "direita": nomes.index(OSSO_MODELO + "RightHand")} + extensao = {lado: pos[:, quadril, 1] - pos[:, i, 1] for lado, i in ossos.items()} + for lado, e in extensao.items(): + picos = [f for f in range(len(e)) if max(0, f - 3) + np.argmax(e[max(0, f - 3) : f + 4]) == f] + lista = ", ".join(f"q{inicio + f} {e[f]:.3f} m a {pos[f, ossos[lado], 2]:.2f} m de altura" for f in picos) + print(f"picos da mão {lado}: {lista}", flush=True) + lado = max(extensao, key=lambda m: extensao[m].max()) + pico = int(np.argmax(extensao[lado])) + alcance = float(extensao[lado][pico]) + outra = "esquerda" if lado == "direita" else "direita" + mao = "ambas" if extensao[outra][pico] >= 0.9 * alcance else lado + + velocidade = np.r_[0, np.linalg.norm(np.diff(pos, axis=0), axis=2).mean(1)] # [f]: de f - 1 a f + a0 = pico + while a0 > 0 and velocidade[a0] >= 0.15 * velocidade.max(): + a0 -= 1 + distancia = np.linalg.norm(pos - pos[a0], axis=2).mean(1) + b0 = next((f for f in range(pico + 1, len(pos)) if distancia[f] < 0.02), len(pos) - 1) + quadros = min(b0 - a0, int(DURACAO_GOLPE * fps)) + cortes = [] + for a in range(a0, pico): + b = a + quadros + if b <= b0 and 0.25 <= (pico - a) / quadros <= 0.75: + seg = pos[a : b + 1] + cortes += [(tranco(misturar(seg, np.broadcast_to(seg[0], seg.shape), n), n), a, b, n) for n in MISTURA] + if not cortes: + falhar(f"nenhum recorte de {quadros} quadros entre q{inicio + a0} e q{inicio + b0} com o impacto no meio") + estimado, a, b, n = min(cortes) + + curvas = acao.layers[0].strips[0].channelbags[0].fcurves + grupos = {} + for curva in curvas: + grupos.setdefault(curva.data_path, []).append(curva) + for caminho, grupo in grupos.items(): + grupo.sort(key=lambda c: c.array_index) + valores = np.array([[c.evaluate(inicio + f) for c in grupo] for f in range(a, b + 1)]) + alvo = np.repeat(valores[:1], len(valores), 0) + quaternio = caminho.endswith("rotation_quaternion") + if quaternio: # q e -q são a mesma rotação: mistura com a do mesmo lado + alvo[(valores * alvo).sum(1) < 0] *= -1 + valores = misturar(valores, alvo, n) + if quaternio: + valores /= np.linalg.norm(valores, axis=1, keepdims=True) + for curva, coluna in zip(grupo, valores.T): + curva.keyframe_points.clear() + curva.keyframe_points.add(len(coluna)) + for f, (ponto, v) in enumerate(zip(curva.keyframe_points, coluna)): + ponto.co = (f, v) # do quadro 0: o exportador grava tempo = quadro / fps e o clipe começa em 0 s + ponto.interpolation = "LINEAR" + curva.update() + enxugar(curvas) + + novo = posicoes_ossos(esqueleto, acao, 0, b - a) + real = tranco(novo, n) + if int(np.argmax(novo[:, quadril, 1] - novo[:, ossos[lado], 1])) != pico - a: + falhar("no clipe recortado a mão não vai mais à frente no quadro do impacto") + acel = np.linalg.norm(pos[2:] - 2 * pos[1:-1] + pos[:-2], axis=2).mean(1).max() + print( + f"golpe natural q{inicio + a0}-q{inicio + b0} ({(b0 - a0) / fps:.3f} s), impacto q{inicio + pico}; " + f"recorte q{inicio + a}-q{inicio + b}, mistura de {n} quadros; tranco na emenda {real:.3f} m/quadro² " + f"(estimado {estimado:.3f}; o golpe original chega a {acel:.3f})", + flush=True, + ) + golpe = { + "duracao": round((b - a) / fps, 3), + "impacto": round((pico - a) / fps, 3), + "mao": mao, + "alcance": round(alcance, 3), + } + if not 0.25 * golpe["duracao"] <= golpe["impacto"] <= 0.75 * golpe["duracao"]: + falhar(f"impacto em {golpe['impacto']} s fora do meio do golpe de {golpe['duracao']} s") + with open(GOLPE, "w", encoding="utf-8") as f: + json.dump(golpe, f, ensure_ascii=False, indent=2) + f.write("\n") + return golpe + + +def exportar(esqueleto, corpo): + os.makedirs(PUBLICO, exist_ok=True) + bpy.ops.object.select_all(action="DESELECT") + for o in (esqueleto, corpo): + o.select_set(True) + esqueleto.animation_data.action = None + for acao in [a for a in bpy.data.actions if a.name not in CLIPES]: # sobras vazias da importação e do assado + bpy.data.actions.remove(acao) + bpy.context.scene.frame_set(1) + bpy.ops.export_scene.gltf( + filepath=GLB, + export_format="GLB", + use_selection=True, + export_yup=True, + export_apply=False, + export_cameras=False, + export_lights=False, + export_morph=False, + export_tangents=False, + export_image_format="JPEG", + export_jpeg_quality=QUALIDADE_JPEG, + export_animations=True, + export_animation_mode="ACTIONS", + export_anim_slide_to_zero=True, + export_force_sampling=False, + export_optimize_animation_size=True, + ) + + +def ler_glb(): + with open(GLB, "rb") as f: + bruto = f.read() + tamanho = struct.unpack_from(" MAX_TRIS: + falhar(f"{tris} triângulos no GLB (máximo {MAX_TRIS})") + if tamanho > MAX_BYTES: + falhar(f"GLB com {tamanho} bytes (máximo {MAX_BYTES})") + extensoes = dados.get("extensionsUsed", []) + if dados.get("cameras") or "KHR_lights_punctual" in extensoes: + falhar("o GLB saiu com câmera ou luz") + if "KHR_materials_specular" in extensoes: + falhar("o GLB saiu com KHR_materials_specular (o three.js passaria a usar o material físico)") + materiais = sorted(m["name"] for m in dados["materials"]) + if materiais != ["corpo", "olhos", "roupa"]: + falhar(f"materiais no GLB: {materiais}") + faltando = [n for n in ["parado", "andar", "atacar", "morrer"] if n not in clipes] + if faltando or len(clipes) != len(CLIPES): + falhar(f"clipes no GLB: {clipes}") + return tris, tamanho, clipes + + +def renderizar(): + """Reabre o GLB exportado (o que o jogo vai ver), mede o repouso e fotografa de frente, parado, com luz branca.""" + bpy.ops.wm.read_factory_settings(use_empty=True) + cena = bpy.context.scene + bpy.ops.import_scene.gltf(filepath=GLB) + esqueleto = next(o for o in cena.objects if o.type == "ARMATURE") + corpo = next(o for o in cena.objects if o.type == "MESH") + esqueleto.data.pose_position = "REST" + bpy.context.view_layer.update() + malha = corpo.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh() + pontos = [corpo.matrix_world @ v.co for v in malha.vertices] + corpo.to_mesh_clear() + pe, topo = min(p.z for p in pontos), max(p.z for p in pontos) + ossos = esqueleto.data.bones # no Blender a frente do glTF (+Z) é -Y: o dedão fica à frente do tornozelo + de_frente = ossos[OSSO_MODELO + "LeftToeBase"].head_local.y < ossos[OSSO_MODELO + "LeftFoot"].head_local.y + esqueleto.data.pose_position = "POSE" + esqueleto.animation_data.action = next(a for a in bpy.data.actions if a.name.startswith("parado")) + cena.frame_set(30) + mundo = bpy.data.worlds.new("neutro") + mundo.use_nodes = True + mundo.node_tree.nodes["Background"].inputs["Color"].default_value = (0.5, 0.5, 0.5, 1) + mundo.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.6 + cena.world = mundo + luz = bpy.data.objects.new("principal", bpy.data.lights.new("principal", "AREA")) + luz.data.energy, luz.data.size = 300, 2 + luz.location = (1.5, -3, 3) + luz.rotation_euler = (Vector((0, 0, 1.2)) - luz.location).to_track_quat("-Z", "Y").to_euler() + cena.collection.objects.link(luz) + camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera")) + cena.collection.objects.link(camera) + cena.camera = camera + ligar_cycles(cena) + os.makedirs(RENDERS, exist_ok=True) + for nome, alvo, distancia in (("frente", 0.9, 3.2), ("perto", 1.4, 1.5)): + camera.location = (0.3 * distancia / 4, -distancia, alvo + 0.1) + camera.rotation_euler = (Vector((0, 0, alvo)) - camera.location).to_track_quat("-Z", "Y").to_euler() + sair(cena, os.path.join(RENDERS, f"zumbi-{nome}.png"), (800, 800), "PNG", "RGB", 64, False) + return pe, topo, de_frente + + +def sala(zumbis, camera_em, alvo, lente): + """Monta a luz do jogo, sala escura azulada e uma lanterna na câmera, com uma cópia do GLB por zumbi. + + zumbis: (variação, (x, y), clipe, atraso em quadros); o atraso desencontra o passo de quem anda junto. + """ + bpy.ops.wm.read_factory_settings(use_empty=True) + cena = bpy.context.scene + for variacao, (x, y), clipe, atraso in zumbis: + antes = set(cena.objects) + bpy.ops.import_scene.gltf(filepath=GLB) + novos = [o for o in cena.objects if o not in antes] + esqueleto = next(o for o in novos if o.type == "ARMATURE") + corpo = next(o for o in novos if o.type == "MESH") + esqueleto.location.x, esqueleto.location.y = x, y + esqueleto.animation_data.action = None + faixa = esqueleto.animation_data.nla_tracks.new() + faixa.strips.new(clipe, 1 - atraso, next(a for a in bpy.data.actions if a.name == clipe)) + if variacao != "a": + slot = next(s for s in corpo.material_slots if s.material.name.startswith("roupa")) + slot.material = slot.material.copy() + base = slot.material.node_tree.nodes["Principled BSDF"].inputs["Base Color"].links[0].from_node + base.image = bpy.data.images.load(os.path.join(PUBLICO, f"roupa-{variacao}.jpg")) + cena.frame_set(30) + mundo = bpy.data.worlds.new("sala") + mundo.use_nodes = True + mundo.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.03, 0.06, 1) + mundo.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.6 + cena.world = mundo + piso = bpy.data.materials.new("piso") + piso.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.025, 0.027, 0.03, 1) + piso.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.85 + for nome, local, rotacao in (("chao", (0, 0, 0), (0, 0, 0)), ("parede", (0, 2.5, 3), (1.5708, 0, 0))): + bpy.ops.mesh.primitive_plane_add(size=10, location=local, rotation=rotacao) + bpy.context.object.name = nome + bpy.context.object.data.materials.append(piso) + camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera")) + camera.data.lens = lente + camera.location = camera_em + camera.rotation_euler = (Vector(alvo) - camera.location).to_track_quat("-Z", "Y").to_euler() + cena.collection.objects.link(camera) + cena.camera = camera + lanterna = bpy.data.objects.new("lanterna", bpy.data.lights.new("lanterna", "SPOT")) + lanterna.data.energy, lanterna.data.spot_size, lanterna.data.spot_blend = 250, 0.9, 0.5 + lanterna.data.color = (1.0, 0.94, 0.84) + lanterna.location = Vector(camera_em) + Vector((0.15, 0.1, -0.15)) # na mão, um pouco abaixo do olho + lanterna.rotation_euler = (Vector(alvo) - lanterna.location).to_track_quat("-Z", "Y").to_euler() + cena.collection.objects.link(lanterna) + ligar_cycles(cena) + return cena + + +def renderizar_variacoes(): + """As três roupas lado a lado, paradas, de corpo inteiro.""" + zumbis = [(v, ((i - 1) * 0.9, 0), "parado", 0) for i, v in enumerate(ROUPAS)] + cena = sala(zumbis, (0, -3.4, 1.55), (0, 0, 0.95), 40) + sair(cena, os.path.join(RENDERS, "zumbi-variacoes.png"), (1200, 800), "PNG", "RGB", 64, False) + + +def renderizar_capa(): + """Abertura da página: as três vindo para a câmera; o terço esquerdo fica vazio e escuro para o texto.""" + zumbis = [("a", (0.35, -0.5), "andar", 0), ("b", (1.25, 0.3), "andar", 11), ("c", (0.0, 1.1), "andar", 23)] + cena = sala(zumbis, (0, -3.6, 1.5), (0.6, 0, 1.0), 35) + with tempfile.TemporaryDirectory() as pasta: + png = os.path.join(pasta, "capa.png") + sair(cena, png, (1600, 900), "PNG", "RGB", 96, False) + render = bpy.data.images.load(png) + imagem = bpy.data.images.new("capa", *render.size) # imagem carregada regrava no formato de origem + imagem.pixels.foreach_set(np.array(render.pixels, np.float32)) + qualidade = gravar_jpeg(imagem, CAPA) + print(f"capa.jpg qualidade {qualidade}, {os.path.getsize(CAPA)} bytes", flush=True) + + +def renderizar_golpe(golpe): + """Confere no GLB a duração do `atacar` e que a mão vai mais à frente no impacto; renderiza de perfil o início, o + meio do preparo, o impacto (com moldura), o meio da volta e o fim, lado a lado, em golpe.png.""" + dados, _ = ler_glb() + animacao = next(a for a in dados["animations"] if a["name"] == "atacar") + tempos = [dados["accessors"][s["input"]] for s in animacao["samplers"]] + comeco, duracao = min(t["min"][0] for t in tempos), max(t["max"][0] for t in tempos) + if comeco > 1e-3 or abs(duracao - golpe["duracao"]) > 1e-3: + falhar(f"atacar vai de {comeco:.3f} a {duracao:.3f} s no GLB; golpe.json diz 0 a {golpe['duracao']} s") + cena = sala([("a", (0, 0), "atacar", 0)], (3.4, -0.25, 1.0), (0, -0.25, 0.95), 45) # perfil: frente à esquerda + esqueleto = next(o for o in cena.objects if o.type == "ARMATURE") + acao = next(a for a in bpy.data.actions if a.name == "atacar") + por_segundo = (acao.frame_range[1] - acao.frame_range[0]) / duracao # quadros da cena por segundo do clipe + ossos = esqueleto.pose.bones + nomes_maos = {"direita": ["RightHand"], "esquerda": ["LeftHand"], "ambas": ["RightHand", "LeftHand"]} + maos = [OSSO_MODELO + m for m in nomes_maos[golpe["mao"]]] + + def no_instante(t): + f = 1 + t * por_segundo + cena.frame_set(int(f), subframe=f - int(f)) + quadril = (esqueleto.matrix_world @ ossos[QUADRIL].head).y + return max(quadril - (esqueleto.matrix_world @ ossos[m].head).y for m in maos) + + extensoes = [no_instante(k / 30) for k in range(round(duracao * 30) + 1)] + impacto = int(np.argmax(extensoes)) / 30 + if abs(impacto - golpe["impacto"]) > 1e-3: + falhar(f"no GLB a mão vai mais à frente em {impacto:.3f} s, não em {golpe['impacto']} s") + print(f"no GLB: mão mais à frente em {impacto:.3f} s, {max(extensoes):.3f} m à frente do quadril", flush=True) + + largura, altura, borda = 440, 600, 8 + quadros = [] + with tempfile.TemporaryDirectory() as pasta: + for i, t in enumerate([0, impacto / 2, impacto, (impacto + duracao) / 2, duracao]): + no_instante(t) + png = os.path.join(pasta, f"{i}.png") + sair(cena, png, (largura, altura), "PNG", "RGB", 32, False) + quadros.append(np.array(bpy.data.images.load(png).pixels, np.float32).reshape(altura, largura, 4)) + moldura = quadros[2] + for faixa in (moldura[:borda], moldura[-borda:], moldura[:, :borda], moldura[:, -borda:]): + faixa[..., :3] = (1.0, 0.2, 0.1) + imagem = bpy.data.images.new("golpe", largura * len(quadros), altura) + imagem.pixels.foreach_set(np.concatenate(quadros, 1).ravel()) + imagem.filepath_raw = os.path.join(RENDERS, "golpe.png") + imagem.file_format = "PNG" + imagem.save() + + +def main(): + obter_fontes() + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.context.scene.render.fps = 30 + esqueleto, corpo = preparar_modelo() + ligar_texturas(corpo) + tris_origem, tris_reduzidos = reduzir(corpo) + print(f"triângulos {tris_origem} -> {tris_reduzidos}", flush=True) + print(f"pele afundada sob a roupa: {afundar_pele(corpo)} vértices", flush=True) + pintar(corpo, esqueleto) + esqueleto.animation_data_create() + quadros = {} + for nome, arquivo in CLIPES.items(): + quadros[nome] = transferir(esqueleto, nome, arquivo) + print(f"clipe {nome} <- {arquivo} quadros {quadros[nome][0]}-{quadros[nome][1]}", flush=True) + golpe = recortar_golpe(esqueleto, *quadros["atacar"]) + exportar(esqueleto, corpo) + proprios = [{"arquivo": "zumbi.glb", "origem": PAGINA, "autor": "Rikindle3D", "licenca": "CC0-1.0"}] + [ + { + "arquivo": f"roupa-{variacao}.jpg", + "origem": "gerada para este jogo", + "autor": "Evandro Fonseca Junior", + "licenca": "propria", + } + for variacao in ROUPAS + if variacao != "a" + ] + [ + { + "arquivo": "capa.jpg", + "origem": "render feito para este jogo a partir de zumbi.glb", + "autor": "Evandro Fonseca Junior", + "licenca": "propria", + } + ] + creditos = [] + if os.path.exists(CREDITOS): # outros scripts também creditam arquivos de public/jogo/: mantém os deles + with open(CREDITOS, encoding="utf-8") as f: + nossos = {c["arquivo"] for c in proprios} + creditos = [c for c in json.load(f) if c["arquivo"] not in nossos] + with open(CREDITOS, "w", encoding="utf-8") as f: + json.dump(proprios + creditos, f, ensure_ascii=False, indent=2) + f.write("\n") + tris, tamanho, clipes = conferir() + pe, topo, de_frente = renderizar() + renderizar_variacoes() + renderizar_capa() + renderizar_golpe(golpe) + altura = topo - pe + if abs(pe) > 0.01 or not ALTURA[0] <= altura <= ALTURA[1]: + falhar(f"pés em {pe:.3f} m e altura {altura:.3f} m no repouso") + if not de_frente: + falhar("o zumbi não está de frente para +Z") + print(f"repouso: pés em {pe:.3f} m, altura {altura:.3f} m", flush=True) + print(f"golpe duracao={golpe['duracao']:.3f} impacto={golpe['impacto']:.3f} mao={golpe['mao']}", flush=True) + print(f"OK zumbi tris={tris} bytes={tamanho} clipes={','.join(clipes)}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/3d/mapa.py b/3d/mapa.py new file mode 100644 index 0000000..43691fe --- /dev/null +++ b/3d/mapa.py @@ -0,0 +1,355 @@ +"""Mapa do escritório do jogo em /jogo: gera a primeira versão do .tmx do Tiled e converte o JSON exportado para a grade da lógica. + +A fonte do mapa é o 3d/mapa/escritorio.tmx, editado à mão no Tiled. Rodar da raiz do repositório (o 3d/mapa.sh faz a cadeia toda): + python3 3d/mapa.py gerar sobrescreve 3d/mapa/escritorio.tmx e 3d/mapa/ladrilhos.png pela semente, apagando as edições + feitas no Tiled; só roda sob `bash 3d/mapa.sh --gerar` + python3 3d/mapa.py converter lê 3d/mapa/escritorio.json (exportado pelo Tiled) e escreve lib/jogo/mapas/escritorio.ts + +Coordenadas em células de 1 m: x cresce para a direita (leste), z para baixo (sul) no desenho; retângulos inclusivos. +""" + +import json +import random +import struct +import sys +import zlib +from pathlib import Path +from xml.sax.saxutils import escape, quoteattr + +SEMENTE = 20261110 +LARGURA, ALTURA, LADO = 44, 32, 32 +RAIZ = Path(__file__).resolve().parent.parent +PASTA = RAIZ / "3d" / "mapa" +SAIDA_TS = RAIZ / "lib" / "jogo" / "mapas" / "escritorio.ts" + +# nome, cor RGB, grade da lógica ("#" bloqueia passo e visão, "M" só o passo, "" é enfeite), cobre a célula inteira +LADRILHOS = [ + ("parede", (55, 58, 70), "#", True), + ("chão", (214, 206, 188), ".", True), + ("mesa", (176, 120, 64), "M", True), + ("cadeira", (40, 110, 200), "", False), + ("armário", (110, 140, 110), "#", True), + ("estante de servidor", (0, 150, 140), "#", True), + ("sofá", (190, 50, 60), "M", True), + ("bancada", (230, 180, 40), "M", True), + ("planta de vaso", (60, 160, 60), "", False), + ("bebedouro", (120, 200, 240), "", False), +] +GID = {nome: i + 1 for i, (nome, *_resto) in enumerate(LADRILHOS)} + +PAREDES = [ + # borda + (0, 0, 43, 0), (0, 31, 43, 31), (0, 0, 0, 31), (43, 0, 43, 31), + # faixas de salas e anel do corredor + (0, 7, 43, 7), (0, 24, 43, 24), (9, 7, 9, 24), (34, 7, 34, 24), + (12, 10, 31, 10), (12, 21, 31, 21), (12, 10, 12, 21), (31, 10, 31, 21), + # divisórias + (15, 0, 15, 7), (28, 0, 28, 7), (0, 16, 9, 16), (34, 16, 43, 16), (17, 24, 17, 31), (24, 24, 24, 31), + # poços dos elevadores, caixa da escada, cabines dos banheiros + (1, 29, 3, 30), (6, 29, 8, 30), (21, 27, 23, 30), (1, 17, 2, 21), +] + +AMBIENTES = [ + ("recepção", 1, 1, 14, 6), + ("sala de reunião", 16, 1, 27, 6), + ("diretoria", 29, 1, 42, 6), + ("copa", 1, 8, 8, 15), + ("banheiros", 1, 17, 8, 23), + ("área de mesas", 13, 11, 30, 20), + ("sala do servidor", 35, 8, 42, 15), + ("almoxarifado", 35, 17, 42, 23), + ("hall dos elevadores", 1, 25, 16, 30), + ("escada", 18, 25, 23, 30), + ("área de serviço", 25, 25, 42, 30), +] + +# trechos do anel, só para espalhar luminárias +CORREDOR = [(10, 8, 33, 9), (10, 22, 33, 23), (10, 10, 11, 21), (32, 10, 33, 21)] + +PORTAS = [ + ("recepção – copa", 4, 7, 5, 7), + ("recepção – corredor", 12, 7, 13, 7), + ("sala de reunião – corredor", 18, 7, 19, 7), + ("sala de reunião – corredor", 24, 7, 25, 7), + ("sala de reunião – diretoria", 28, 3, 28, 4), + ("diretoria – corredor", 30, 7, 31, 7), + ("copa – corredor", 9, 11, 9, 12), + ("banheiros – corredor", 9, 19, 9, 20), + ("banheiros – hall dos elevadores", 4, 24, 5, 24), + ("hall dos elevadores – corredor", 13, 24, 14, 24), + ("hall dos elevadores – escada", 17, 27, 17, 28), + ("escada – corredor", 20, 24, 21, 24), + ("área de serviço – corredor", 28, 24, 29, 24), + ("área de serviço – almoxarifado", 38, 24, 39, 24), + ("almoxarifado – corredor", 34, 19, 34, 20), + ("sala do servidor – almoxarifado", 38, 16, 39, 16), + ("sala do servidor – corredor", 34, 11, 34, 12), + ("área de mesas – corredor norte", 21, 10, 22, 10), + ("área de mesas – corredor norte", 28, 10, 29, 10), + ("área de mesas – corredor sul", 21, 21, 22, 21), + ("área de mesas – corredor oeste", 12, 15, 12, 16), + ("área de mesas – corredor leste", 31, 15, 31, 16), +] + +ILHAS = [(15, 13, 19, 14), (23, 13, 27, 14), (15, 17, 19, 18), (23, 17, 27, 18)] + +MOVEIS = [ + # recepção + ("sofá", 3, 1, 5, 1), ("sofá", 1, 3, 1, 6), ("bancada", 8, 3, 11, 3), + ("cadeira", 9, 2, 9, 2), ("cadeira", 10, 2, 10, 2), ("planta de vaso", 1, 1, 1, 1), ("bebedouro", 14, 1, 14, 1), + # sala de reunião + ("mesa", 18, 3, 25, 4), ("cadeira", 17, 3, 17, 3), ("cadeira", 26, 4, 26, 4), + ("planta de vaso", 16, 1, 16, 1), ("planta de vaso", 27, 1, 27, 1), + # diretoria + ("mesa", 35, 3, 38, 4), ("cadeira", 37, 2, 37, 2), ("cadeira", 36, 5, 36, 5), ("cadeira", 38, 5, 38, 5), + ("sofá", 29, 1, 31, 1), ("armário", 42, 1, 42, 4), ("planta de vaso", 41, 6, 41, 6), + # copa + ("bancada", 1, 11, 1, 14), ("bancada", 1, 15, 4, 15), ("armário", 7, 15, 8, 15), ("mesa", 4, 11, 5, 12), + ("cadeira", 3, 11, 3, 11), ("cadeira", 6, 12, 6, 12), ("cadeira", 4, 10, 4, 10), ("cadeira", 5, 13, 5, 13), + ("bebedouro", 1, 8, 1, 8), + # banheiros + ("bancada", 5, 17, 8, 17), ("planta de vaso", 8, 23, 8, 23), + # hall dos elevadores + ("sofá", 11, 30, 14, 30), ("planta de vaso", 1, 25, 1, 25), ("bebedouro", 16, 25, 16, 25), + # área de serviço + ("armário", 27, 30, 32, 30), ("bancada", 40, 27, 42, 28), ("bebedouro", 33, 25, 33, 25), + # almoxarifado + ("armário", 42, 17, 42, 19), ("armário", 37, 20, 39, 20), ("armário", 35, 23, 36, 23), + # sala do servidor + ("estante de servidor", 37, 10, 42, 10), ("estante de servidor", 37, 13, 42, 13), + # área de mesas + *[("mesa", *ilha) for ilha in ILHAS], + ("armário", 30, 11, 30, 13), ("planta de vaso", 13, 11, 13, 11), ("planta de vaso", 13, 20, 13, 20), + ("planta de vaso", 30, 20, 30, 20), ("bebedouro", 30, 14, 30, 14), + # corredor + ("planta de vaso", 10, 8, 10, 8), ("planta de vaso", 33, 8, 33, 8), + ("planta de vaso", 10, 23, 10, 23), ("planta de vaso", 33, 23, 33, 23), +] + +INICIO = (7, 5) +NASCEDOUROS = [ + ("elevador 1", 2, 27), ("elevador 2", 7, 27), ("escada", 19, 29), ("banheiros", 6, 21), + ("fundos, área de serviço", 26, 29), ("fundos, área de serviço", 41, 30), ("fundos, almoxarifado", 41, 22), +] + +# célula de chão e para onde o cartaz olha; a parede fica do lado oposto +CARTAZES = [ + (7, 1, "S"), (12, 1, "S"), (21, 1, "S"), (16, 4, "L"), (36, 1, "S"), (8, 14, "O"), (20, 9, "N"), + (10, 18, "L"), (33, 14, "O"), (25, 22, "S"), (20, 11, "S"), (13, 18, "L"), (11, 25, "S"), +] +FACES = {"N": (0, 1), "S": (0, -1), "L": (-1, 0), "O": (1, 0)} # deslocamento até a parede + +NOTAS = """Escritório do jogo em /jogo, 1 célula = 1 m. Este arquivo é a fonte do mapa: abra no Tiled (`flatpak run org.mapeditor.Tiled`), edite, salve e rode `bash 3d/mapa.sh`, que exporta o JSON, converte a grade da lógica e monta o 3D. `bash 3d/mapa.sh --gerar` refaz este arquivo pelo 3d/mapa.py e apaga as edições feitas aqui. +Camadas: planta (parede e chão), moveis (a propriedade grade de cada ladrilho diz como entra na lógica: # bloqueia passo e visão, M bloqueia o passo e deixa o tiro passar, vazio é só enfeite), marcas (objetos pelo tipo). +Tipos em marcas: inicio (ponto do jogador), nascedouro (ponto; o nome diz o lugar), ambiente (retângulo com o nome da sala), porta (retângulo sobre as células abertas na parede), luminaria (ponto no teto), cartaz (ponto na face da parede; face = para onde o cartaz olha: N para cima no desenho, S para baixo, L para a direita, O para a esquerda; imagem de 1 a 4).""" + + +def celulas(x0, z0, x1, z1): + return [(x, z) for z in range(z0, z1 + 1) for x in range(x0, x1 + 1)] + + +def png(largura, altura, rgba): + """PNG RGBA 8 bits sem metadados (os mesmos pixels dão os mesmos bytes).""" + linhas = b"".join(b"\0" + rgba[y * largura * 4 : (y + 1) * largura * 4] for y in range(altura)) + + def bloco(tipo, dados): + return struct.pack(">I", len(dados)) + tipo + dados + struct.pack(">I", zlib.crc32(tipo + dados)) + + cabecalho = struct.pack(">IIBBBBB", largura, altura, 8, 6, 0, 0, 0) + return b"\x89PNG\r\n\x1a\n" + bloco(b"IHDR", cabecalho) + bloco(b"IDAT", zlib.compress(linhas, 9)) + bloco(b"IEND", b"") + + +def imagem_ladrilhos(): + """Uma linha de ladrilhos de 32 px: cheios com borda escura; enfeites são um quadrado menor sobre fundo transparente.""" + largura = LADO * len(LADRILHOS) + pixels = bytearray(largura * LADO * 4) + for i, (_nome, cor, _grade, cheio) in enumerate(LADRILHOS): + escura = tuple(c // 2 for c in cor) + margem = 0 if cheio else 8 + for y in range(margem, LADO - margem): + for x in range(margem, LADO - margem): + borda = min(x - margem, y - margem, LADO - 1 - margem - x, LADO - 1 - margem - y) < 2 + p = (y * largura + i * LADO + x) * 4 + pixels[p : p + 4] = bytes((*(escura if borda else cor), 255)) + return png(largura, LADO, bytes(pixels)) + + +def montar_planta(): + planta = [["chão"] * LARGURA for _ in range(ALTURA)] + for parede in PAREDES: + for x, z in celulas(*parede): + planta[z][x] = "parede" + for _nome, *porta in PORTAS: + for x, z in celulas(*porta): + planta[z][x] = "chão" + return planta + + +def montar_moveis(rng): + moveis = [[None] * LARGURA for _ in range(ALTURA)] + for tipo, *retangulo in MOVEIS: + for x, z in celulas(*retangulo): + moveis[z][x] = tipo + # cadeiras dos dois lados de cada ilha; algumas vagas ficam sem cadeira + for x0, z0, x1, z1 in ILHAS: + for x in range(x0, x1 + 1): + for z in (z0 - 1, z1 + 1): + if rng.random() < 0.8: + moveis[z][x] = "cadeira" + return moveis + + +def objeto(id_, tipo, nome, x, y, largura=None, altura=None, propriedades=()): + attrs = f'id="{id_}" name={quoteattr(nome)} type="{tipo}" x="{x:g}" y="{y:g}"' + if largura is not None: + attrs += f' width="{largura:g}" height="{altura:g}"' + corpo = [] + if propriedades: + corpo.append(" ") + corpo += [f' ' for n, t, v in propriedades] + corpo.append(" ") + if largura is None: + corpo.append(" ") + return [f" ", *corpo, " "] + + +def luminarias(x0, z0, x1, z1): + """Pontos a cada ~5 m dentro do retângulo, em pixels, arredondados à meia célula.""" + largura, altura = x1 - x0 + 1, z1 - z0 + 1 + nx, nz = max(1, round(largura / 5)), max(1, round(altura / 5)) + return [ + (round((x0 + largura * (i + 0.5) / nx) * 2) * LADO // 2, round((z0 + altura * (j + 0.5) / nz) * 2) * LADO // 2) + for j in range(nz) + for i in range(nx) + ] + + +def gerar(): + rng = random.Random(SEMENTE) + planta = montar_planta() + moveis = montar_moveis(rng) + marcas = [] + total = 0 + + def novo(*args, **kwargs): + nonlocal total + total += 1 + marcas.extend(objeto(total, *args, **kwargs)) + + novo("inicio", "inicio", (INICIO[0] + 0.5) * LADO, (INICIO[1] + 0.5) * LADO) + for nome, x, z in NASCEDOUROS: + novo("nascedouro", nome, (x + 0.5) * LADO, (z + 0.5) * LADO) + for nome, x0, z0, x1, z1 in AMBIENTES: + novo("ambiente", nome, x0 * LADO, z0 * LADO, (x1 - x0 + 1) * LADO, (z1 - z0 + 1) * LADO) + for nome, x0, z0, x1, z1 in PORTAS: + novo("porta", nome, x0 * LADO, z0 * LADO, (x1 - x0 + 1) * LADO, (z1 - z0 + 1) * LADO) + for retangulo in [r[1:] for r in AMBIENTES] + CORREDOR: + for x, y in luminarias(*retangulo): + novo("luminaria", "luminaria", x, y) + for x, z, face in CARTAZES: + dx, dz = FACES[face] + assert planta[z][x] == "chão" and planta[z + dz][x + dx] == "parede", (x, z, face) + px = (x + 0.5 + dx / 2) * LADO + pz = (z + 0.5 + dz / 2) * LADO + propriedades = [("face", "", face), ("imagem", 'type="int" ', rng.randint(1, 4))] + novo("cartaz", "cartaz", px, pz, propriedades=propriedades) + + def csv(grade): + return ",\n".join(",".join(str(GID[c] if c else 0) for c in linha) for linha in grade) + + tiles = [] + for i, (nome, _cor, grade, _cheio) in enumerate(LADRILHOS): + tiles += [ + f' ', + " ", + f" ", + f' ', + " ", + " ", + ] + marcas_txt = "\n".join(marcas) + tmx = "\n".join( + [ + '', + f'', + " ", + f' {escape(NOTAS)}', + f' ', + " ", + f' ', + f' ', + *tiles, + " ", + f' ', + ' ', + csv(planta), + "", + " ", + f' ', + ' ', + csv(moveis), + "", + " ", + ' ', + marcas_txt, + " ", + "", + "", + ] + ) + PASTA.mkdir(parents=True, exist_ok=True) + (PASTA / "escritorio.tmx").write_text(tmx, encoding="utf-8") + (PASTA / "ladrilhos.png").write_bytes(imagem_ladrilhos()) + + +def converter(): + dados = json.loads((PASTA / "escritorio.json").read_text(encoding="utf-8")) + largura, altura, lado = dados["width"], dados["height"], dados["tilewidth"] + grade_por_gid = {} + for tileset in dados["tilesets"]: + for tile in tileset.get("tiles", []): + propriedades = {p["name"]: p["value"] for p in tile.get("properties", [])} + grade_por_gid[tileset["firstgid"] + tile["id"]] = propriedades["grade"] + camadas = {c["name"]: c for c in dados["layers"]} + linhas = [["#"] * largura for _ in range(altura)] + for nome in ("planta", "moveis"): + for i, gid in enumerate(camadas[nome]["data"]): + grade = grade_por_gid.get(gid & 0x1FFFFFFF, "") # bits altos: ladrilho espelhado ou girado + if grade: + linhas[i // largura][i % largura] = grade + ambientes, portas = [], [] + for obj in camadas["marcas"]["objects"]: + x, z = int(obj["x"] // lado), int(obj["y"] // lado) + if obj["type"] in ("inicio", "nascedouro"): + linhas[z][x] = "J" if obj["type"] == "inicio" else "N" + elif obj["type"] in ("ambiente", "porta"): + retangulo = {"x": x, "z": z, "largura": round(obj["width"] / lado), "altura": round(obj["height"] / lado)} + if obj["type"] == "ambiente": + ambientes.append({"nome": obj["name"], **retangulo}) + else: + portas.append(retangulo) + + def lista(itens): + return "[\n" + "".join(f" {json.dumps(i, ensure_ascii=False)},\n" for i in itens) + "]" + + retangulo_ts = "x: number; z: number; largura: number; altura: number" + ts = f"""// Gerado por `bash 3d/mapa.sh` (`python3 3d/mapa.py converter`) a partir de 3d/mapa/escritorio.tmx: não edite à mão. + +/** Planta do escritório para `lerMapa`: `#` parede ou móvel alto, `M` móvel baixo, `.` chão, `J` início, `N` nascedouro. */ +export const ESCRITORIO: readonly string[] = {lista("".join(linha) for linha in linhas)}; + +/** Salas com nome, em células (x para a direita, z para baixo). */ +export const AMBIENTES: readonly {{ nome: string; {retangulo_ts} }}[] = {lista(ambientes)}; + +/** Células abertas nas paredes, em células. */ +export const PORTAS: readonly {{ {retangulo_ts} }}[] = {lista(portas)}; +""" + SAIDA_TS.parent.mkdir(parents=True, exist_ok=True) + SAIDA_TS.write_text(ts, encoding="utf-8") + + +if __name__ == "__main__": + comandos = {"gerar": gerar, "converter": converter} + if len(sys.argv) != 2 or sys.argv[1] not in comandos: + sys.exit("uso: python3 3d/mapa.py gerar|converter") + comandos[sys.argv[1]]() diff --git a/3d/mapa.sh b/3d/mapa.sh new file mode 100755 index 0000000..e72518a --- /dev/null +++ b/3d/mapa.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Refaz o mapa do escritório a partir de 3d/mapa/escritorio.tmx, a fonte editada no Tiled: exporta JSON e PNG pelo +# Tiled (sem janela), converte para a grade da lógica e monta o 3D no Blender. Rodar de qualquer lugar: +# bash 3d/mapa.sh só exporta, converte e monta +# bash 3d/mapa.sh --gerar antes, refaz o .tmx pelo 3d/mapa.py (apaga o que foi editado à mão no Tiled) +set -euo pipefail +cd "$(dirname "$0")/.." + +tiled() { + env -u DISPLAY -u WAYLAND_DISPLAY flatpak run --env=QT_QPA_PLATFORM=offscreen --command="$1" org.mapeditor.Tiled "${@:2}" +} + +case "${1:-}" in + "") ;; + --gerar) + echo "aviso: --gerar sobrescreve 3d/mapa/escritorio.tmx pelo 3d/mapa.py; o que foi editado no Tiled se perde" >&2 + python3 3d/mapa.py gerar + ;; + *) + echo "uso: bash 3d/mapa.sh [--gerar]" >&2 + exit 2 + ;; +esac +tiled tiled --export-map json "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.json" +tiled tmxrasterizer "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.png" +python3 3d/mapa.py converter +./node_modules/.bin/biome format --write lib/jogo/mapas/escritorio.ts 3d/mapa/escritorio.json +blender -b -P 3d/mapa_3d.py diff --git a/3d/mapa/escritorio.json b/3d/mapa/escritorio.json new file mode 100644 index 0000000..2013254 --- /dev/null +++ b/3d/mapa/escritorio.json @@ -0,0 +1,1829 @@ +{ + "compressionlevel": -1, + "height": 32, + "infinite": false, + "layers": [ + { + "data": [ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, + 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, + 2, 2, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 2, 2, 1, 1, + 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, + 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, + 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 1, 1, 2, 2, 1, 2, 2, 2, 2, 2, + 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, + 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, + 2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, + 2, 1, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, + 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, + 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, + 1, 1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 2, + 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, + 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, + 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 1, 1, + 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, + 2, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, + 1, 1, 1, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 1, 1, 1, + 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + ], + "height": 32, + "id": 1, + "name": "planta", + "opacity": 1, + "type": "tilelayer", + "visible": true, + "width": 44, + "x": 0, + "y": 0 + }, + { + "data": [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 7, + 7, 7, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, + 0, 7, 7, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 5, 0, 0, 7, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8, + 0, 0, 0, 0, 0, 4, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, + 3, 3, 3, 0, 0, 0, 5, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 0, + 0, 0, 5, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 4, 0, 0, 0, 0, 0, + 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, + 0, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 6, + 6, 6, 6, 0, 0, 8, 0, 4, 3, 3, 0, 0, 0, 0, 0, 0, 0, 9, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 8, 0, 0, 3, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 4, 4, 4, 0, 0, 0, 4, + 0, 4, 0, 4, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, + 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 0, 0, 0, 3, 3, 3, 3, 3, + 0, 0, 5, 0, 0, 0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 0, 0, 8, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 0, 0, 0, 3, 3, 3, 3, 3, 0, 0, 10, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8, 0, 0, 5, 5, 0, 0, 0, + 0, 0, 0, 4, 4, 4, 4, 4, 0, 0, 0, 4, 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, + 4, 4, 0, 4, 0, 0, 0, 0, 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, + 0, 0, 0, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 0, 0, 0, 3, + 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 4, 4, 0, 0, 0, 4, 0, 4, 4, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, + 0, 0, 0, 0, 0, 5, 5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 5, 5, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 7, 7, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 5, 5, 5, 5, 5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ], + "height": 32, + "id": 2, + "name": "moveis", + "opacity": 1, + "type": "tilelayer", + "visible": true, + "width": 44, + "x": 0, + "y": 0 + }, + { + "draworder": "topdown", + "id": 3, + "name": "marcas", + "objects": [ + { + "height": 0, + "id": 1, + "name": "inicio", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "inicio", + "visible": true, + "width": 0, + "x": 240, + "y": 176 + }, + { + "height": 0, + "id": 2, + "name": "elevador 1", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "nascedouro", + "visible": true, + "width": 0, + "x": 80, + "y": 880 + }, + { + "height": 0, + "id": 3, + "name": "elevador 2", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "nascedouro", + "visible": true, + "width": 0, + "x": 240, + "y": 880 + }, + { + "height": 0, + "id": 4, + "name": "escada", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "nascedouro", + "visible": true, + "width": 0, + "x": 624, + "y": 944 + }, + { + "height": 0, + "id": 5, + "name": "banheiros", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "nascedouro", + "visible": true, + "width": 0, + "x": 208, + "y": 688 + }, + { + "height": 0, + "id": 6, + "name": "fundos, \u00e1rea de servi\u00e7o", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "nascedouro", + "visible": true, + "width": 0, + "x": 848, + "y": 944 + }, + { + "height": 0, + "id": 7, + "name": "fundos, \u00e1rea de servi\u00e7o", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "nascedouro", + "visible": true, + "width": 0, + "x": 1328, + "y": 976 + }, + { + "height": 0, + "id": 8, + "name": "fundos, almoxarifado", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "nascedouro", + "visible": true, + "width": 0, + "x": 1328, + "y": 720 + }, + { + "height": 192, + "id": 9, + "name": "recep\u00e7\u00e3o", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 448, + "x": 32, + "y": 32 + }, + { + "height": 192, + "id": 10, + "name": "sala de reuni\u00e3o", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 384, + "x": 512, + "y": 32 + }, + { + "height": 192, + "id": 11, + "name": "diretoria", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 448, + "x": 928, + "y": 32 + }, + { + "height": 256, + "id": 12, + "name": "copa", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 256, + "x": 32, + "y": 256 + }, + { + "height": 224, + "id": 13, + "name": "banheiros", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 256, + "x": 32, + "y": 544 + }, + { + "height": 320, + "id": 14, + "name": "\u00e1rea de mesas", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 576, + "x": 416, + "y": 352 + }, + { + "height": 256, + "id": 15, + "name": "sala do servidor", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 256, + "x": 1120, + "y": 256 + }, + { + "height": 224, + "id": 16, + "name": "almoxarifado", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 256, + "x": 1120, + "y": 544 + }, + { + "height": 192, + "id": 17, + "name": "hall dos elevadores", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 512, + "x": 32, + "y": 800 + }, + { + "height": 192, + "id": 18, + "name": "escada", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 192, + "x": 576, + "y": 800 + }, + { + "height": 192, + "id": 19, + "name": "\u00e1rea de servi\u00e7o", + "opacity": 1, + "rotation": 0, + "type": "ambiente", + "visible": true, + "width": 576, + "x": 800, + "y": 800 + }, + { + "height": 32, + "id": 20, + "name": "recep\u00e7\u00e3o \u2013 copa", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 128, + "y": 224 + }, + { + "height": 32, + "id": 21, + "name": "recep\u00e7\u00e3o \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 384, + "y": 224 + }, + { + "height": 32, + "id": 22, + "name": "sala de reuni\u00e3o \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 576, + "y": 224 + }, + { + "height": 32, + "id": 23, + "name": "sala de reuni\u00e3o \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 768, + "y": 224 + }, + { + "height": 64, + "id": 24, + "name": "sala de reuni\u00e3o \u2013 diretoria", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 32, + "x": 896, + "y": 96 + }, + { + "height": 32, + "id": 25, + "name": "diretoria \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 960, + "y": 224 + }, + { + "height": 64, + "id": 26, + "name": "copa \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 32, + "x": 288, + "y": 352 + }, + { + "height": 64, + "id": 27, + "name": "banheiros \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 32, + "x": 288, + "y": 608 + }, + { + "height": 32, + "id": 28, + "name": "banheiros \u2013 hall dos elevadores", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 128, + "y": 768 + }, + { + "height": 32, + "id": 29, + "name": "hall dos elevadores \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 416, + "y": 768 + }, + { + "height": 64, + "id": 30, + "name": "hall dos elevadores \u2013 escada", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 32, + "x": 544, + "y": 864 + }, + { + "height": 32, + "id": 31, + "name": "escada \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 640, + "y": 768 + }, + { + "height": 32, + "id": 32, + "name": "\u00e1rea de servi\u00e7o \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 896, + "y": 768 + }, + { + "height": 32, + "id": 33, + "name": "\u00e1rea de servi\u00e7o \u2013 almoxarifado", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 1216, + "y": 768 + }, + { + "height": 64, + "id": 34, + "name": "almoxarifado \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 32, + "x": 1088, + "y": 608 + }, + { + "height": 32, + "id": 35, + "name": "sala do servidor \u2013 almoxarifado", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 1216, + "y": 512 + }, + { + "height": 64, + "id": 36, + "name": "sala do servidor \u2013 corredor", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 32, + "x": 1088, + "y": 352 + }, + { + "height": 32, + "id": 37, + "name": "\u00e1rea de mesas \u2013 corredor norte", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 672, + "y": 320 + }, + { + "height": 32, + "id": 38, + "name": "\u00e1rea de mesas \u2013 corredor norte", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 896, + "y": 320 + }, + { + "height": 32, + "id": 39, + "name": "\u00e1rea de mesas \u2013 corredor sul", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 64, + "x": 672, + "y": 672 + }, + { + "height": 64, + "id": 40, + "name": "\u00e1rea de mesas \u2013 corredor oeste", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 32, + "x": 384, + "y": 480 + }, + { + "height": 64, + "id": 41, + "name": "\u00e1rea de mesas \u2013 corredor leste", + "opacity": 1, + "rotation": 0, + "type": "porta", + "visible": true, + "width": 32, + "x": 992, + "y": 480 + }, + { + "height": 0, + "id": 42, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 112, + "y": 128 + }, + { + "height": 0, + "id": 43, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 256, + "y": 128 + }, + { + "height": 0, + "id": 44, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 400, + "y": 128 + }, + + { + "height": 0, + "id": 45, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 608, + "y": 128 + }, + { + "height": 0, + "id": 46, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 800, + "y": 128 + }, + { + "height": 0, + "id": 47, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1008, + "y": 128 + }, + { + "height": 0, + "id": 48, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1152, + "y": 128 + }, + { + "height": 0, + "id": 49, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1296, + "y": 128 + }, + { + "height": 0, + "id": 50, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 96, + "y": 320 + }, + { + "height": 0, + "id": 51, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 224, + "y": 320 + }, + { + "height": 0, + "id": 52, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 96, + "y": 448 + }, + { + "height": 0, + "id": 53, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 224, + "y": 448 + }, + { + "height": 0, + "id": 54, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 96, + "y": 656 + }, + { + "height": 0, + "id": 55, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 224, + "y": 656 + }, + { + "height": 0, + "id": 56, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 480, + "y": 432 + }, + { + "height": 0, + "id": 57, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 640, + "y": 432 + }, + { + "height": 0, + "id": 58, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 768, + "y": 432 + }, + { + "height": 0, + "id": 59, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 928, + "y": 432 + }, + { + "height": 0, + "id": 60, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 480, + "y": 592 + }, + { + "height": 0, + "id": 61, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 640, + "y": 592 + }, + { + "height": 0, + "id": 62, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 768, + "y": 592 + }, + { + "height": 0, + "id": 63, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 928, + "y": 592 + }, + { + "height": 0, + "id": 64, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1184, + "y": 320 + }, + { + "height": 0, + "id": 65, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1312, + "y": 320 + }, + { + "height": 0, + "id": 66, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1184, + "y": 448 + }, + { + "height": 0, + "id": 67, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1312, + "y": 448 + }, + { + "height": 0, + "id": 68, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1184, + "y": 656 + }, + { + "height": 0, + "id": 69, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1312, + "y": 656 + }, + { + "height": 0, + "id": 70, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 112, + "y": 896 + }, + { + "height": 0, + "id": 71, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 288, + "y": 896 + }, + { + "height": 0, + "id": 72, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 464, + "y": 896 + }, + { + "height": 0, + "id": 73, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 672, + "y": 896 + }, + { + "height": 0, + "id": 74, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 864, + "y": 896 + }, + { + "height": 0, + "id": 75, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1024, + "y": 896 + }, + { + "height": 0, + "id": 76, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1152, + "y": 896 + }, + { + "height": 0, + "id": 77, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1312, + "y": 896 + }, + { + "height": 0, + "id": 78, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 400, + "y": 288 + }, + { + "height": 0, + "id": 79, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 544, + "y": 288 + }, + { + "height": 0, + "id": 80, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 704, + "y": 288 + }, + { + "height": 0, + "id": 81, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 864, + "y": 288 + }, + { + "height": 0, + "id": 82, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1008, + "y": 288 + }, + { + "height": 0, + "id": 83, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 400, + "y": 736 + }, + { + "height": 0, + "id": 84, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 544, + "y": 736 + }, + { + "height": 0, + "id": 85, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 704, + "y": 736 + }, + { + "height": 0, + "id": 86, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 864, + "y": 736 + }, + { + "height": 0, + "id": 87, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1008, + "y": 736 + }, + { + "height": 0, + "id": 88, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 352, + "y": 416 + }, + + { + "height": 0, + "id": 89, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 352, + "y": 608 + }, + { + "height": 0, + "id": 90, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1056, + "y": 416 + }, + { + "height": 0, + "id": 91, + "name": "luminaria", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "luminaria", + "visible": true, + "width": 0, + "x": 1056, + "y": 608 + }, + { + "height": 0, + "id": 92, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "S" + }, + { + "name": "imagem", + "type": "int", + "value": 1 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 240, + "y": 32 + }, + { + "height": 0, + "id": 93, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "S" + }, + { + "name": "imagem", + "type": "int", + "value": 1 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 400, + "y": 32 + }, + { + "height": 0, + "id": 94, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "S" + }, + { + "name": "imagem", + "type": "int", + "value": 3 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 688, + "y": 32 + }, + { + "height": 0, + "id": 95, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "L" + }, + { + "name": "imagem", + "type": "int", + "value": 3 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 512, + "y": 144 + }, + { + "height": 0, + "id": 96, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "S" + }, + { + "name": "imagem", + "type": "int", + "value": 2 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 1168, + "y": 32 + }, + { + "height": 0, + "id": 97, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "O" + }, + { + "name": "imagem", + "type": "int", + "value": 2 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 288, + "y": 464 + }, + { + "height": 0, + "id": 98, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "N" + }, + { + "name": "imagem", + "type": "int", + "value": 4 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 656, + "y": 320 + }, + { + "height": 0, + "id": 99, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "L" + }, + { + "name": "imagem", + "type": "int", + "value": 3 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 320, + "y": 592 + }, + { + "height": 0, + "id": 100, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "O" + }, + { + "name": "imagem", + "type": "int", + "value": 2 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 1088, + "y": 464 + }, + { + "height": 0, + "id": 101, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "S" + }, + { + "name": "imagem", + "type": "int", + "value": 1 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 816, + "y": 704 + }, + { + "height": 0, + "id": 102, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "S" + }, + { + "name": "imagem", + "type": "int", + "value": 2 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 656, + "y": 352 + }, + { + "height": 0, + "id": 103, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "L" + }, + { + "name": "imagem", + "type": "int", + "value": 1 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 416, + "y": 592 + }, + { + "height": 0, + "id": 104, + "name": "cartaz", + "opacity": 1, + "point": true, + "properties": [ + { + "name": "face", + "type": "string", + "value": "S" + }, + { + "name": "imagem", + "type": "int", + "value": 3 + } + ], + "rotation": 0, + "type": "cartaz", + "visible": true, + "width": 0, + "x": 368, + "y": 800 + } + ], + "opacity": 1, + "type": "objectgroup", + "visible": true, + "x": 0, + "y": 0 + } + ], + "nextlayerid": 4, + "nextobjectid": 105, + "orientation": "orthogonal", + "properties": [ + { + "name": "notas", + "type": "string", + "value": "Escrit\u00f3rio do jogo em \/jogo, 1 c\u00e9lula = 1 m. Este arquivo sai de `python3 3d\/mapa.py gerar` (o `bash 3d\/mapa.sh` roda isso e sobrescreve edi\u00e7\u00f5es feitas aqui; para mudar o mapa de vez, mude o 3d\/mapa.py).\nCamadas: planta (parede e ch\u00e3o), moveis (a propriedade grade de cada ladrilho diz como entra na l\u00f3gica: # bloqueia passo e vis\u00e3o, M bloqueia o passo e deixa o tiro passar, vazio \u00e9 s\u00f3 enfeite), marcas (objetos pelo tipo).\nTipos em marcas: inicio (ponto do jogador), nascedouro (ponto; o nome diz o lugar), ambiente (ret\u00e2ngulo com o nome da sala), porta (ret\u00e2ngulo sobre as c\u00e9lulas abertas na parede), luminaria (ponto no teto), cartaz (ponto na face da parede; face = para onde o cartaz olha: N para cima no desenho, S para baixo, L para a direita, O para a esquerda; imagem de 1 a 4)." + }, + { + "name": "semente", + "type": "int", + "value": 20261110 + } + ], + "renderorder": "right-down", + "tiledversion": "1.12.2", + "tileheight": 32, + "tilesets": [ + { + "columns": 10, + "firstgid": 1, + "image": "ladrilhos.png", + "imageheight": 32, + "imagewidth": 320, + "margin": 0, + "name": "ladrilhos", + "spacing": 0, + "tilecount": 10, + "tileheight": 32, + "tiles": [ + { + "id": 0, + "properties": [ + { + "name": "grade", + "type": "string", + "value": "#" + }, + { + "name": "nome", + "type": "string", + "value": "parede" + } + ] + }, + { + "id": 1, + "properties": [ + { + "name": "grade", + "type": "string", + "value": "." + }, + { + "name": "nome", + "type": "string", + "value": "ch\u00e3o" + } + ] + }, + { + "id": 2, + "properties": [ + { + "name": "grade", + "type": "string", + "value": "M" + }, + { + "name": "nome", + "type": "string", + "value": "mesa" + } + ] + }, + { + "id": 3, + "properties": [ + { + "name": "grade", + "type": "string", + "value": "" + }, + { + "name": "nome", + "type": "string", + "value": "cadeira" + } + ] + }, + { + "id": 4, + "properties": [ + { + "name": "grade", + "type": "string", + "value": "#" + }, + { + "name": "nome", + "type": "string", + "value": "arm\u00e1rio" + } + ] + }, + { + "id": 5, + "properties": [ + { + "name": "grade", + "type": "string", + "value": "#" + }, + { + "name": "nome", + "type": "string", + "value": "estante de servidor" + } + ] + }, + { + "id": 6, + "properties": [ + { + "name": "grade", + "type": "string", + "value": "M" + }, + { + "name": "nome", + "type": "string", + "value": "sof\u00e1" + } + ] + }, + { + "id": 7, + "properties": [ + { + "name": "grade", + "type": "string", + "value": "M" + }, + { + "name": "nome", + "type": "string", + "value": "bancada" + } + ] + }, + { + "id": 8, + "properties": [ + { + "name": "grade", + "type": "string", + "value": "" + }, + { + "name": "nome", + "type": "string", + "value": "planta de vaso" + } + ] + }, + { + "id": 9, + "properties": [ + { + "name": "grade", + "type": "string", + "value": "" + }, + { + "name": "nome", + "type": "string", + "value": "bebedouro" + } + ] + } + ], + "tilewidth": 32 + } + ], + "tilewidth": 32, + "type": "map", + "version": "1.10", + "width": 44 +} diff --git a/3d/mapa/escritorio.png b/3d/mapa/escritorio.png new file mode 100644 index 0000000..f963198 Binary files /dev/null and b/3d/mapa/escritorio.png differ diff --git a/3d/mapa/escritorio.tmx b/3d/mapa/escritorio.tmx new file mode 100644 index 0000000..108f1bc --- /dev/null +++ b/3d/mapa/escritorio.tmx @@ -0,0 +1,477 @@ + + + + Escritório do jogo em /jogo, 1 célula = 1 m. Este arquivo é a fonte do mapa: abra no Tiled (`flatpak run org.mapeditor.Tiled`), edite, salve e rode `bash 3d/mapa.sh`, que exporta o JSON, converte a grade da lógica e monta o 3D. `bash 3d/mapa.sh --gerar` refaz este arquivo pelo 3d/mapa.py e apaga as edições feitas aqui. +Camadas: planta (parede e chão), moveis (a propriedade grade de cada ladrilho diz como entra na lógica: # bloqueia passo e visão, M bloqueia o passo e deixa o tiro passar, vazio é só enfeite), marcas (objetos pelo tipo). +Tipos em marcas: inicio (ponto do jogador), nascedouro (ponto; o nome diz o lugar), ambiente (retângulo com o nome da sala), porta (retângulo sobre as células abertas na parede), luminaria (ponto no teto), cartaz (ponto na face da parede; face = para onde o cartaz olha: N para cima no desenho, S para baixo, L para a direita, O para a esquerda; imagem de 1 a 4). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, +1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,1,1,1,2,2,1,1,1,1,1,1,2,2,1,1,1,1,2,2,1,1,1,1,2,2,1,1,1,1,2,2,1,1,1,1,1,1,1,1,1,1,1,1, +1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,1,2,2,1,1,1,1,1,1,1,1,1,2,2,1,1,1,1,1,2,2,1,1,2,2,1,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1, +1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,2,2,1,1,1,1, +1,1,1,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1, +1,1,1,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1, +1,1,1,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1, +1,1,1,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1, +1,1,1,2,2,2,2,2,2,1,2,2,1,1,1,1,1,1,1,1,1,2,2,1,1,1,1,1,1,1,1,1,2,2,1,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1, +1,1,1,1,2,2,1,1,1,1,1,1,1,2,2,1,1,1,1,1,2,2,1,1,1,1,1,1,2,2,1,1,1,1,1,1,1,1,2,2,1,1,1,1, +1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,1,1,1,2,2,1,1,1,2,2,2,2,2,2,2,2,1,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,1,1,1,2,2,1,1,1,2,2,2,2,2,2,2,2,1,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1, +1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 + + + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,9,0,7,7,7,0,0,0,0,0,0,0,0,10,0,9,0,0,0,0,0,0,0,0,0,0,9,0,7,7,7,0,0,0,0,0,0,0,0,0,0,5,0, +0,0,0,0,0,0,0,0,0,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,0,0,0,5,0, +0,7,0,0,0,0,0,0,8,8,8,8,0,0,0,0,0,4,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,3,3,3,3,0,0,0,5,0, +0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,4,0,0,0,0,0,0,0,0,3,3,3,3,0,0,0,5,0, +0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,4,0,0,0,0,0, +0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,10,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,6,6,6,6,0, +0,8,0,4,3,3,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,8,0,0,3,3,4,0,0,0,0,0,0,0,0,4,0,4,4,4,0,0,0,4,0,4,0,4,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,8,0,0,0,4,0,0,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,5,0,0,0,0,0,0,6,6,6,6,6,6,0, +0,8,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,8,8,8,8,0,0,5,5,0,0,0,0,0,0,4,4,4,4,4,0,0,0,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,4,4,0,4,0,0,0,0,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,8,8,8,8,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,4,4,0,0,0,4,0,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,5,5,5,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,9,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,5,5,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,8,8,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,8,8,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,7,7,7,7,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/3d/mapa/ladrilhos.png b/3d/mapa/ladrilhos.png new file mode 100644 index 0000000..deff07e Binary files /dev/null and b/3d/mapa/ladrilhos.png differ diff --git a/3d/texturas/calca.jpg b/3d/texturas/calca.jpg new file mode 100644 index 0000000..65e45bb Binary files /dev/null and b/3d/texturas/calca.jpg differ diff --git a/3d/texturas/camisa.jpg b/3d/texturas/camisa.jpg new file mode 100644 index 0000000..24fd6dd Binary files /dev/null and b/3d/texturas/camisa.jpg differ diff --git a/3d/texturas/cenario-carpete.png b/3d/texturas/cenario-carpete.png new file mode 100644 index 0000000..efbbcaf Binary files /dev/null and b/3d/texturas/cenario-carpete.png differ diff --git a/3d/texturas/cenario-cartaz-1.png b/3d/texturas/cenario-cartaz-1.png new file mode 100644 index 0000000..972108c Binary files /dev/null and b/3d/texturas/cenario-cartaz-1.png differ diff --git a/3d/texturas/cenario-cartaz-2.png b/3d/texturas/cenario-cartaz-2.png new file mode 100644 index 0000000..d085792 Binary files /dev/null and b/3d/texturas/cenario-cartaz-2.png differ diff --git a/3d/texturas/cenario-cartaz-3.png b/3d/texturas/cenario-cartaz-3.png new file mode 100644 index 0000000..f20fc2d Binary files /dev/null and b/3d/texturas/cenario-cartaz-3.png differ diff --git a/3d/texturas/cenario-cartaz-4.png b/3d/texturas/cenario-cartaz-4.png new file mode 100644 index 0000000..71875e8 Binary files /dev/null and b/3d/texturas/cenario-cartaz-4.png differ diff --git a/3d/texturas/cenario-forro.png b/3d/texturas/cenario-forro.png new file mode 100644 index 0000000..31bad25 Binary files /dev/null and b/3d/texturas/cenario-forro.png differ diff --git a/3d/texturas/cenario-madeira.png b/3d/texturas/cenario-madeira.png new file mode 100644 index 0000000..4260531 Binary files /dev/null and b/3d/texturas/cenario-madeira.png differ diff --git a/3d/texturas/cenario-parede.png b/3d/texturas/cenario-parede.png new file mode 100644 index 0000000..c330448 Binary files /dev/null and b/3d/texturas/cenario-parede.png differ diff --git a/3d/texturas/pele.jpg b/3d/texturas/pele.jpg new file mode 100644 index 0000000..038f82c Binary files /dev/null and b/3d/texturas/pele.jpg differ diff --git a/3d/texturas/sangue.jpg b/3d/texturas/sangue.jpg new file mode 100644 index 0000000..39a7c8e Binary files /dev/null and b/3d/texturas/sangue.jpg differ diff --git a/app/jogo/Bancada.tsx b/app/jogo/Bancada.tsx new file mode 100644 index 0000000..3b952e2 --- /dev/null +++ b/app/jogo/Bancada.tsx @@ -0,0 +1,458 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import type { Material, Object3D, Texture } from "three"; + +export type ResultadoDaBancada = + | { + p50: number; + p95: number; + max: number; + quadros: number; + chamadas: number; + triangulos: number; + heapMB: number | null; + zumbis: number; + largura: number; + altura: number; + pixelRatio: number; + gpu: string | null; + trabalhoP50: number; + trabalhoP95: number; + geometrias: number; + texturas: number; + } + | { erro: "sem-webgl" }; + +declare global { + interface Window { + __bancada?: ResultadoDaBancada; + } +} + +type Estado = + | { fase: "carregando" } + | { fase: "medindo"; faltam: number } + | { fase: "pronto"; resultado: ResultadoDaBancada }; + +const AQUECIMENTO_MS = 2000; +const VERDE = "#c5f04a"; +/** Velocidade do zumbi comum (m/s), a mesma da lógica do jogo. */ +const PASSO = 1.2; + +function umaCasa(valor: number) { + return Math.round(valor * 10) / 10; +} + +/** Percentil pelo posto mais próximo, sobre a lista já ordenada. */ +function percentil(ordenados: number[], p: number) { + const indice = Math.ceil((p / 100) * ordenados.length) - 1; + return ordenados[Math.min(ordenados.length - 1, Math.max(0, indice))]; +} + +/** Descarta geometrias, materiais e as texturas deles em tudo o que está na cena. */ +function descartarCena(raiz: Object3D) { + const texturas = new Set(); + const materiais = new Set(); + raiz.traverse((objeto) => { + const malha = objeto as Object3D & { + geometry?: { dispose(): void }; + material?: Material | Material[]; + }; + malha.geometry?.dispose(); + const lista = Array.isArray(malha.material) + ? malha.material + : malha.material + ? [malha.material] + : []; + for (const material of lista) materiais.add(material); + }); + for (const material of materiais) { + for (const valor of Object.values(material)) + if ((valor as Texture | null)?.isTexture) texturas.add(valor as Texture); + material.dispose(); + } + for (const textura of texturas) textura.dispose(); +} + +/** + * Monta a sala, os zumbis e os drones em `host`, aquece 2 s, mede `seg` segundos e chama `aoTerminar`. Devolve a + * função que desfaz tudo, ou `null` se `vivo()` virou falso no meio da carga. + */ +async function rodarBancada( + host: HTMLElement, + zumbis: number, + seg: number, + vivo: () => boolean, + aoMedir: (faltam: number) => void, + aoTerminar: (resultado: ResultadoDaBancada) => void, +) { + // carga sob demanda: o three e o modelo só baixam quando a bancada monta, fora do pacote da página + const [THREE, { carregarZumbi, criarZumbi }, { GLTFLoader }] = + await Promise.all([ + import("three"), + import("./zumbis3d"), + import("three/examples/jsm/loaders/GLTFLoader.js"), + ]); + if (!vivo()) return null; + + let renderer: InstanceType; + const teste = document.createElement("canvas").getContext("webgl2"); + if (!teste) { + aoTerminar({ erro: "sem-webgl" }); + return () => {}; + } + teste.getExtension("WEBGL_lose_context")?.loseContext(); + try { + renderer = new THREE.WebGLRenderer({ + antialias: true, + powerPreference: "high-performance", + }); + } catch { + aoTerminar({ erro: "sem-webgl" }); + return () => {}; + } + const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); + renderer.setPixelRatio(pixelRatio); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.shadowMap.enabled = true; + const canvas = renderer.domElement; + canvas.setAttribute("aria-hidden", "true"); + canvas.style.cssText = "display:block;width:100%;height:100%"; + + const [modelo, bloco] = await Promise.all([ + carregarZumbi(), + new GLTFLoader().loadAsync("/3d/bloco.glb"), + ]); + if (!vivo()) { + renderer.dispose(); + renderer.forceContextLoss(); + return null; + } + host.append(canvas); + + const scene = new THREE.Scene(); + const escuro = new THREE.Color("#0b0c0e"); + scene.background = escuro; + scene.fog = new THREE.Fog(escuro, 6, 24); + + // a sala: uma caixa vista por dentro, 28 × 3 × 17 m, piso no y = 0 + const parede = new THREE.MeshStandardMaterial({ + color: "#3a3d42", + roughness: 0.95, + side: THREE.BackSide, + }); + const piso = new THREE.MeshStandardMaterial({ + color: "#2a2b2d", + roughness: 1, + side: THREE.BackSide, + }); + const sala = new THREE.Mesh(new THREE.BoxGeometry(28, 3, 17), [ + parede, + parede, + parede, + piso, + parede, + parede, + ]); + sala.position.y = 1.5; + sala.receiveShadow = true; + scene.add(sala); + + scene.add(new THREE.AmbientLight("#ffffff", 0.25)); + const sol = new THREE.DirectionalLight("#fff3e0", 1.6); + sol.position.set(4, 8, 3); + sol.castShadow = true; + sol.shadow.mapSize.set(1024, 1024); + Object.assign(sol.shadow.camera, { + left: -15, + right: 15, + top: 10, + bottom: -10, + near: 1, + far: 20, + }); + sol.shadow.camera.updateProjectionMatrix(); + scene.add(sol); + + const camera = new THREE.PerspectiveCamera( + 70, + window.innerWidth / window.innerHeight, + 0.1, + 40, + ); + camera.position.set(0, 1.6, 0); + + const andantes = Array.from({ length: zumbis }, (_, i) => { + const zumbi = criarZumbi(modelo); + zumbi.raiz.traverse((objeto) => { + objeto.castShadow = true; + }); + zumbi.tocar("andar"); + zumbi.avancar(i * 0.37); + scene.add(zumbi.raiz); + return { + zumbi, + raio: 3 + (6 * i) / Math.max(1, zumbis - 1), + angulo: i * 2.39996, + sentido: i % 2 ? -1 : 1, + }; + }); + + const geometriaDoBloco = ( + bloco.scene.children[0] as InstanceType + ).geometry; + const verde = new THREE.MeshStandardMaterial({ + color: VERDE, + roughness: 0.6, + }); + const drones = Array.from({ length: 3 }, () => { + const drone = new THREE.Group(); + for (let i = 0; i < 8; i += 1) { + const peca = new THREE.Mesh(geometriaDoBloco, verde); + peca.scale.setScalar(0.12); + peca.position.set( + (i & 1 ? 1 : -1) * 0.07, + (i & 2 ? 1 : -1) * 0.07, + (i & 4 ? 1 : -1) * 0.07, + ); + peca.castShadow = true; + drone.add(peca); + } + scene.add(drone); + return drone; + }); + + const redimensionar = () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); + }; + window.addEventListener("resize", redimensionar); + + const duracoes: number[] = []; + const trabalhosBrutos: number[] = []; + let inicio = -1; + let anterior = -1; + let ultimoAviso = -1; + const quadro = (agora: number) => { + const comecoDoQuadro = performance.now(); + if (inicio < 0) inicio = agora; + const dt = anterior < 0 ? 0 : Math.min(0.1, (agora - anterior) / 1000); + const decorrido = agora - inicio; + if (decorrido > AQUECIMENTO_MS && anterior >= 0) + duracoes.push(agora - anterior); + anterior = agora; + + const t = decorrido / 1000; + camera.rotation.y = t * 0.15; + for (const andante of andantes) { + andante.angulo += (andante.sentido * PASSO * dt) / andante.raio; + const { raiz } = andante.zumbi; + raiz.position.set( + Math.sin(andante.angulo) * andante.raio, + 0, + Math.cos(andante.angulo) * andante.raio, + ); + raiz.rotation.y = andante.angulo + (andante.sentido * Math.PI) / 2; + andante.zumbi.avancar(dt); + } + drones.forEach((drone, i) => { + const angulo = camera.rotation.y + Math.PI + (i - 1) * 0.5; + drone.position.set( + Math.sin(angulo) * 2.5, + 2.1 + Math.sin(t * 2 + i) * 0.1, + Math.cos(angulo) * 2.5, + ); + drone.rotation.y = t + i; + }); + renderer.render(scene, camera); + if (decorrido > AQUECIMENTO_MS) + trabalhosBrutos.push(performance.now() - comecoDoQuadro); + + const medido = Math.max(0, decorrido - AQUECIMENTO_MS) / 1000; + const faltam = Math.max(0, Math.ceil(seg - medido)); + if (faltam !== ultimoAviso) { + ultimoAviso = faltam; + aoMedir(faltam); + } + if (medido < seg) return; + + renderer.setAnimationLoop(null); + const ordenados = [...duracoes].sort((a, b) => a - b); + const trabalhos = [...trabalhosBrutos].sort((a, b) => a - b); + const tamanho = renderer.getSize(new THREE.Vector2()); + const gl = renderer.getContext(); + const depuracao = gl.getExtension("WEBGL_debug_renderer_info"); + const memoria = ( + performance as Performance & { memory?: { usedJSHeapSize: number } } + ).memory; + aoTerminar({ + p50: umaCasa(percentil(ordenados, 50)), + p95: umaCasa(percentil(ordenados, 95)), + max: umaCasa(ordenados[ordenados.length - 1] ?? 0), + quadros: duracoes.length, + chamadas: renderer.info.render.calls, + triangulos: renderer.info.render.triangles, + heapMB: memoria ? umaCasa(memoria.usedJSHeapSize / 1048576) : null, + zumbis, + largura: tamanho.x, + altura: tamanho.y, + pixelRatio, + gpu: depuracao + ? String(gl.getParameter(depuracao.UNMASKED_RENDERER_WEBGL)) + : null, + trabalhoP50: umaCasa(percentil(trabalhos, 50)), + trabalhoP95: umaCasa(percentil(trabalhos, 95)), + geometrias: renderer.info.memory.geometries, + texturas: renderer.info.memory.textures, + }); + }; + renderer.setAnimationLoop(quadro); + + return () => { + renderer.setAnimationLoop(null); + window.removeEventListener("resize", redimensionar); + descartarCena(scene); + for (const andante of andantes) andante.zumbi.descartar(); + sol.dispose(); + renderer.dispose(); + renderer.forceContextLoss(); + canvas.remove(); + }; +} + +/** A bancada de desempenho: `zumbis` e `seg` já vêm conferidos e dentro dos limites pela página. */ +export default function Bancada({ + zumbis, + seg, +}: { + zumbis: number; + seg: number; +}) { + const palco = useRef(null); + const [rodada, setRodada] = useState(0); + const [estado, setEstado] = useState({ fase: "carregando" }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: `rodada` só existe para medir de novo + useEffect(() => { + const host = palco.current; + if (!host) return; + let ativo = true; + let desfazer: (() => void) | null = null; + delete window.__bancada; + setEstado({ fase: "carregando" }); + rodarBancada( + host, + zumbis, + seg, + () => ativo, + (faltam) => { + if (ativo) setEstado({ fase: "medindo", faltam }); + }, + (resultado) => { + if (!ativo) return; + window.__bancada = resultado; + setEstado({ fase: "pronto", resultado }); + }, + ).then((parar) => { + if (ativo) desfazer = parar; + else parar?.(); + }); + return () => { + ativo = false; + desfazer?.(); + }; + }, [zumbis, seg, rodada]); + + // acima do cabeçalho (z-9000) e do WhatsApp, abaixo da barra de cookies (z-9980) + return ( +
+
+
+

Bancada do jogo

+ {estado.fase === "carregando" && ( +

Carregando…

+ )} + {estado.fase === "medindo" && ( +

+ Medindo… {estado.faltam} s +

+ )} +
+ {estado.fase === "pronto" && ( + setRodada((n) => n + 1)} + /> + )} +
+ ); +} + +function Resultado({ + resultado, + seg, + medirDeNovo, +}: { + resultado: ResultadoDaBancada; + seg: number; + medirDeNovo: () => void; +}) { + if ("erro" in resultado) + return ( +

+ Este navegador não desenha 3D. +

+ ); + const linhas: Array<[string, string]> = [ + ["p50", `${resultado.p50} ms`], + ["p95", `${resultado.p95} ms`], + ["máx", `${resultado.max} ms`], + ["quadros", String(resultado.quadros)], + ["zumbis", String(resultado.zumbis)], + ["chamadas", String(resultado.chamadas)], + ["triângulos", resultado.triangulos.toLocaleString("pt-BR")], + ["memória", resultado.heapMB === null ? "—" : `${resultado.heapMB} MB`], + [ + "tela", + `${resultado.largura}×${resultado.altura} @${resultado.pixelRatio}`, + ], + ["GPU", resultado.gpu ?? "—"], + ["Trabalho por quadro", `p95 ${resultado.trabalhoP95} ms`], + ["trabalho p50", `${resultado.trabalhoP50} ms`], + ["monitor (estimado)", `${Math.round(1000 / resultado.p50)} Hz`], + ]; + return ( +
+
+ {linhas.map(([nome, valor]) => ( +
+
{nome}
+
{valor}
+
+ ))} +
+
+ + {[8, 16, 24].map((n) => ( + + {n} zumbis + + ))} +
+
+ ); +} diff --git a/app/jogo/Hud.test.tsx b/app/jogo/Hud.test.tsx new file mode 100644 index 0000000..cfbcd55 --- /dev/null +++ b/app/jogo/Hud.test.tsx @@ -0,0 +1,122 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Retrato } from "./contrato"; +import Hud from "./Hud"; + +const base: Retrato = { + fase: "horda", + horda: 1, + problemaAtual: "site-que-ninguem-acha", + restantes: 7, + vida: 80, + arma: 0, + municao: 30, + placar: { jogador: 4, drones: 2 }, + feed: [{ passo: 1, texto: "Site que aparece no Google eliminou um zumbi" }], + drones: [{ problemaId: "site-que-ninguem-acha", eliminacoes: 2 }], + pausado: false, +}; + +function montar(parcial: Partial = {}, mudo = false) { + const acoes = { + aoAlternarMudo: jest.fn(), + aoContinuar: jest.fn(), + aoSair: jest.fn(), + }; + render(); + return acoes; +} + +describe("Hud", () => { + it("na horda mostra horda, problema, faltam, placar, vida, arma, feed e drones", () => { + montar(); + expect( + screen.getByText( + "Horda 2 de 3 · Site que ninguém acha no Google · faltam 7", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Você 4 · Drones 2")).toBeInTheDocument(); + expect(screen.getByRole("progressbar", { name: "Vida" })).toHaveAttribute( + "aria-valuenow", + "80", + ); + expect(screen.getByText("Pistola · ∞")).toBeInTheDocument(); + const feed = screen.getByRole("list", { name: "Últimas eliminações" }); + expect(feed).toHaveAttribute("aria-live", "polite"); + expect(feed).toHaveTextContent("eliminou um zumbi"); + expect(screen.getByText("2 eliminações")).toBeInTheDocument(); + expect(screen.queryByText(/Resolvido:/)).not.toBeInTheDocument(); + expect(screen.queryByText("Pausado")).not.toBeInTheDocument(); + }); + + it("mostra a munição da automática", () => { + montar({ arma: 1, municao: 17 }); + expect(screen.getByText("Automática · 17")).toBeInTheDocument(); + }); + + it("no chefão mostra Chefão e o nome", () => { + montar({ fase: "chefao", horda: 3 }); + expect( + screen.getByText("Chefão · Site que ninguém acha no Google"), + ).toBeInTheDocument(); + }); + + it("na pausa entre hordas mostra o cartão Resolvido", () => { + montar({ fase: "pausa", restantes: 0 }); + expect( + screen.getByText("Resolvido: Site que ninguém acha no Google"), + ).toBeInTheDocument(); + expect( + screen.getByText("Site que aparece no Google entrou para o seu time"), + ).toBeInTheDocument(); + }); + + it("no cartão Resolvido o feed fica apagado para não passar por baixo dele", () => { + montar({ fase: "pausa" }); + const feed = screen.getByRole("list", { name: "Últimas eliminações" }); + expect(feed).toHaveClass("opacity-0"); + expect(feed).toHaveTextContent("eliminou um zumbi"); + }); + + it.each(["inicio", "vitoria", "derrota"] as const)( + "na fase %s não mostra horda nem cartão", + (fase) => { + montar({ fase }); + expect(screen.queryByText(/Horda \d/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Resolvido:/)).not.toBeInTheDocument(); + }, + ); + + it("pausado mostra o painel com Continuar, Som e Sair", async () => { + const acoes = montar({ pausado: true }); + expect(screen.getByRole("dialog", { name: "Pausado" })).toBeInTheDocument(); + const continuar = screen.getByRole("button", { name: "Continuar" }); + expect(continuar).toHaveFocus(); + await userEvent.click(continuar); + await userEvent.click(screen.getByRole("button", { name: "Som ligado" })); + await userEvent.click(screen.getByRole("button", { name: "Sair" })); + expect(acoes.aoContinuar).toHaveBeenCalledTimes(1); + expect(acoes.aoAlternarMudo).toHaveBeenCalledTimes(1); + expect(acoes.aoSair).toHaveBeenCalledTimes(1); + }); + + it("o botão de mudo do canto está sempre à vista", async () => { + const acoes = montar({}, true); + await userEvent.click(screen.getByRole("button", { name: "Ligar o som" })); + expect(acoes.aoAlternarMudo).toHaveBeenCalledTimes(1); + }); + + it("só os botões capturam o ponteiro", () => { + const { container } = render( + , + ); + expect(container.firstElementChild).toHaveClass("pointer-events-none"); + expect(screen.getByRole("button")).toHaveClass("pointer-events-auto"); + }); +}); diff --git a/app/jogo/Hud.tsx b/app/jogo/Hud.tsx new file mode 100644 index 0000000..e367bc6 --- /dev/null +++ b/app/jogo/Hud.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { type CSSProperties, useEffect, useRef } from "react"; +import { problemaPorId } from "@/lib/jogo/problemas"; +import { ARMAS, HORDAS, JOGADOR } from "@/lib/jogo/valores"; +import type { Retrato } from "./contrato"; +import { TOQUE_TOPO } from "./medidas"; + +const AREA_TOPO = "env(safe-area-inset-top)"; +const AREA_ESQUERDA = "env(safe-area-inset-left)"; +const AREA_DIREITA = "env(safe-area-inset-right)"; +/** Centro do aviso: 22% da altura, no terço de cima, longe da mira. */ +const AVISO_CENTRO = "22%"; +/** Barra do topo (botão de 44 px entre duas folgas) onde o feed começa. */ +const BARRA_TOPO = `calc(max(var(--sp-2), ${AREA_TOPO}) + 44px + var(--sp-2))`; +/** No toque o feed termina acima do botão mais alto, com uma folga. */ +const FEED_ALTURA_MAXIMA = `calc(100dvh - ${BARRA_TOPO} - ${TOQUE_TOPO} - var(--sp-2))`; + +type Props = { + retrato: Retrato; + mudo: boolean; + aoAlternarMudo(): void; + aoContinuar(): void; + aoSair(): void; +}; + +const fundo = + "bg-[rgba(10,10,10,.72)] font-[family-name:var(--font-mono)] tracking-[0.02em] text-[#f4f2ed]"; +const painel = `${fundo} text-[13px]`; +const botao = + "pointer-events-auto inline-flex min-h-11 items-center justify-center border border-white/40 bg-[var(--ink)] px-sp-4 font-display text-[15px] font-medium text-[#f4f2ed] hover:bg-white/15"; + +function Alto({ retrato }: { retrato: Retrato }) { + const problema = problemaPorId(retrato.problemaAtual)?.nome ?? ""; + if (retrato.fase === "chefao") return <>Chefão · {problema}; + return ( + <> + Horda {retrato.horda + 1} de {HORDAS.tamanhos.length} · {problema} · + faltam {retrato.restantes} + + ); +} + +function IconeSom({ mudo }: { mudo: boolean }) { + return ( + + ); +} + +export default function Hud({ + retrato, + mudo, + aoAlternarMudo, + aoContinuar, + aoSair, +}: Props) { + const continuar = useRef(null); + useEffect(() => { + if (retrato.pausado) continuar.current?.focus(); + }, [retrato.pausado]); + + const arma = ARMAS[retrato.arma]; + const municao = + retrato.arma === 0 ? "∞" : arma.gasta ? String(retrato.municao) : null; + const resolvido = + retrato.fase === "pausa" ? problemaPorId(retrato.problemaAtual) : undefined; + const emJogo = retrato.fase === "horda" || retrato.fase === "chefao"; + const vida = Math.max(0, Math.min(JOGADOR.vida, Math.round(retrato.vida))); + + return ( +
+