Compare commits

..
Author SHA1 Message Date
evandro 67b32fcab3 feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s
Etapa 1 inteira (logica com 100% de cobertura, cena 3D, toque, sons, bancada de desempenho, e2e) e a base da etapa 2 (mapa novo no Tiled, texturas, imagens dos itens, sons conferidos, golpe recortado e medido). Pagina /jogo fora do menu e do sitemap ate a medicao no celular.
2026-11-10 19:37:10 -03:00
118 changed files with 16730 additions and 0 deletions

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ jobs:
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --runInBand
- run: pnpm test:jogo
- run: pnpm build
audit:
+2
View File
@@ -64,3 +64,5 @@ next-env.d.ts
/3d/out/
/3d/__pycache__/
/3d/fontes/
/3d/ferramentas/
+30
View File
@@ -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/<pacote>/<pacote>_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/<pacote>.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 |
+333
View File
@@ -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 <zip>
"""
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()
+34
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+1094
View File
File diff suppressed because it is too large. Load diff
+355
View File
@@ -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(" <properties>")
corpo += [f' <property name="{n}" {t}value={quoteattr(str(v))}/>' for n, t, v in propriedades]
corpo.append(" </properties>")
if largura is None:
corpo.append(" <point/>")
return [f" <object {attrs}>", *corpo, " </object>"]
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' <tile id="{i}">',
" <properties>",
f" <property name=\"nome\" value={quoteattr(nome)}/>",
f' <property name="grade" value="{grade}"/>',
" </properties>",
" </tile>",
]
marcas_txt = "\n".join(marcas)
tmx = "\n".join(
[
'<?xml version="1.0" encoding="UTF-8"?>',
f'<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="{LARGURA}" height="{ALTURA}" tilewidth="{LADO}" tileheight="{LADO}" infinite="0" nextlayerid="4" nextobjectid="{total + 1}">',
" <properties>",
f' <property name="notas">{escape(NOTAS)}</property>',
f' <property name="semente" type="int" value="{SEMENTE}"/>',
" </properties>",
f' <tileset firstgid="1" name="ladrilhos" tilewidth="{LADO}" tileheight="{LADO}" tilecount="{len(LADRILHOS)}" columns="{len(LADRILHOS)}">',
f' <image source="ladrilhos.png" width="{LADO * len(LADRILHOS)}" height="{LADO}"/>',
*tiles,
" </tileset>",
f' <layer id="1" name="planta" width="{LARGURA}" height="{ALTURA}">',
' <data encoding="csv">',
csv(planta),
"</data>",
" </layer>",
f' <layer id="2" name="moveis" width="{LARGURA}" height="{ALTURA}">',
' <data encoding="csv">',
csv(moveis),
"</data>",
" </layer>",
' <objectgroup id="3" name="marcas">',
marcas_txt,
" </objectgroup>",
"</map>",
"",
]
)
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]]()
Executable
+28
View File
@@ -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
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+477
View File
@@ -0,0 +1,477 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="44" height="32" tilewidth="32" tileheight="32" infinite="0" nextlayerid="4" nextobjectid="105">
<properties>
<property name="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).</property>
<property name="semente" type="int" value="20261110"/>
</properties>
<tileset firstgid="1" name="ladrilhos" tilewidth="32" tileheight="32" tilecount="10" columns="10">
<image source="ladrilhos.png" width="320" height="32"/>
<tile id="0">
<properties>
<property name="nome" value="parede"/>
<property name="grade" value="#"/>
</properties>
</tile>
<tile id="1">
<properties>
<property name="nome" value="chão"/>
<property name="grade" value="."/>
</properties>
</tile>
<tile id="2">
<properties>
<property name="nome" value="mesa"/>
<property name="grade" value="M"/>
</properties>
</tile>
<tile id="3">
<properties>
<property name="nome" value="cadeira"/>
<property name="grade" value=""/>
</properties>
</tile>
<tile id="4">
<properties>
<property name="nome" value="armário"/>
<property name="grade" value="#"/>
</properties>
</tile>
<tile id="5">
<properties>
<property name="nome" value="estante de servidor"/>
<property name="grade" value="#"/>
</properties>
</tile>
<tile id="6">
<properties>
<property name="nome" value="sofá"/>
<property name="grade" value="M"/>
</properties>
</tile>
<tile id="7">
<properties>
<property name="nome" value="bancada"/>
<property name="grade" value="M"/>
</properties>
</tile>
<tile id="8">
<properties>
<property name="nome" value="planta de vaso"/>
<property name="grade" value=""/>
</properties>
</tile>
<tile id="9">
<properties>
<property name="nome" value="bebedouro"/>
<property name="grade" value=""/>
</properties>
</tile>
</tileset>
<layer id="1" name="planta" width="44" height="32">
<data encoding="csv">
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
</data>
</layer>
<layer id="2" name="moveis" width="44" height="32">
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,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
</data>
</layer>
<objectgroup id="3" name="marcas">
<object id="1" name="inicio" type="inicio" x="240" y="176">
<point/>
</object>
<object id="2" name="elevador 1" type="nascedouro" x="80" y="880">
<point/>
</object>
<object id="3" name="elevador 2" type="nascedouro" x="240" y="880">
<point/>
</object>
<object id="4" name="escada" type="nascedouro" x="624" y="944">
<point/>
</object>
<object id="5" name="banheiros" type="nascedouro" x="208" y="688">
<point/>
</object>
<object id="6" name="fundos, área de serviço" type="nascedouro" x="848" y="944">
<point/>
</object>
<object id="7" name="fundos, área de serviço" type="nascedouro" x="1328" y="976">
<point/>
</object>
<object id="8" name="fundos, almoxarifado" type="nascedouro" x="1328" y="720">
<point/>
</object>
<object id="9" name="recepção" type="ambiente" x="32" y="32" width="448" height="192">
</object>
<object id="10" name="sala de reunião" type="ambiente" x="512" y="32" width="384" height="192">
</object>
<object id="11" name="diretoria" type="ambiente" x="928" y="32" width="448" height="192">
</object>
<object id="12" name="copa" type="ambiente" x="32" y="256" width="256" height="256">
</object>
<object id="13" name="banheiros" type="ambiente" x="32" y="544" width="256" height="224">
</object>
<object id="14" name="área de mesas" type="ambiente" x="416" y="352" width="576" height="320">
</object>
<object id="15" name="sala do servidor" type="ambiente" x="1120" y="256" width="256" height="256">
</object>
<object id="16" name="almoxarifado" type="ambiente" x="1120" y="544" width="256" height="224">
</object>
<object id="17" name="hall dos elevadores" type="ambiente" x="32" y="800" width="512" height="192">
</object>
<object id="18" name="escada" type="ambiente" x="576" y="800" width="192" height="192">
</object>
<object id="19" name="área de serviço" type="ambiente" x="800" y="800" width="576" height="192">
</object>
<object id="20" name="recepção – copa" type="porta" x="128" y="224" width="64" height="32">
</object>
<object id="21" name="recepção – corredor" type="porta" x="384" y="224" width="64" height="32">
</object>
<object id="22" name="sala de reunião – corredor" type="porta" x="576" y="224" width="64" height="32">
</object>
<object id="23" name="sala de reunião – corredor" type="porta" x="768" y="224" width="64" height="32">
</object>
<object id="24" name="sala de reunião – diretoria" type="porta" x="896" y="96" width="32" height="64">
</object>
<object id="25" name="diretoria – corredor" type="porta" x="960" y="224" width="64" height="32">
</object>
<object id="26" name="copa – corredor" type="porta" x="288" y="352" width="32" height="64">
</object>
<object id="27" name="banheiros – corredor" type="porta" x="288" y="608" width="32" height="64">
</object>
<object id="28" name="banheiros – hall dos elevadores" type="porta" x="128" y="768" width="64" height="32">
</object>
<object id="29" name="hall dos elevadores – corredor" type="porta" x="416" y="768" width="64" height="32">
</object>
<object id="30" name="hall dos elevadores – escada" type="porta" x="544" y="864" width="32" height="64">
</object>
<object id="31" name="escada – corredor" type="porta" x="640" y="768" width="64" height="32">
</object>
<object id="32" name="área de serviço – corredor" type="porta" x="896" y="768" width="64" height="32">
</object>
<object id="33" name="área de serviço – almoxarifado" type="porta" x="1216" y="768" width="64" height="32">
</object>
<object id="34" name="almoxarifado – corredor" type="porta" x="1088" y="608" width="32" height="64">
</object>
<object id="35" name="sala do servidor – almoxarifado" type="porta" x="1216" y="512" width="64" height="32">
</object>
<object id="36" name="sala do servidor – corredor" type="porta" x="1088" y="352" width="32" height="64">
</object>
<object id="37" name="área de mesas – corredor norte" type="porta" x="672" y="320" width="64" height="32">
</object>
<object id="38" name="área de mesas – corredor norte" type="porta" x="896" y="320" width="64" height="32">
</object>
<object id="39" name="área de mesas – corredor sul" type="porta" x="672" y="672" width="64" height="32">
</object>
<object id="40" name="área de mesas – corredor oeste" type="porta" x="384" y="480" width="32" height="64">
</object>
<object id="41" name="área de mesas – corredor leste" type="porta" x="992" y="480" width="32" height="64">
</object>
<object id="42" name="luminaria" type="luminaria" x="112" y="128">
<point/>
</object>
<object id="43" name="luminaria" type="luminaria" x="256" y="128">
<point/>
</object>
<object id="44" name="luminaria" type="luminaria" x="400" y="128">
<point/>
</object>
<object id="45" name="luminaria" type="luminaria" x="608" y="128">
<point/>
</object>
<object id="46" name="luminaria" type="luminaria" x="800" y="128">
<point/>
</object>
<object id="47" name="luminaria" type="luminaria" x="1008" y="128">
<point/>
</object>
<object id="48" name="luminaria" type="luminaria" x="1152" y="128">
<point/>
</object>
<object id="49" name="luminaria" type="luminaria" x="1296" y="128">
<point/>
</object>
<object id="50" name="luminaria" type="luminaria" x="96" y="320">
<point/>
</object>
<object id="51" name="luminaria" type="luminaria" x="224" y="320">
<point/>
</object>
<object id="52" name="luminaria" type="luminaria" x="96" y="448">
<point/>
</object>
<object id="53" name="luminaria" type="luminaria" x="224" y="448">
<point/>
</object>
<object id="54" name="luminaria" type="luminaria" x="96" y="656">
<point/>
</object>
<object id="55" name="luminaria" type="luminaria" x="224" y="656">
<point/>
</object>
<object id="56" name="luminaria" type="luminaria" x="480" y="432">
<point/>
</object>
<object id="57" name="luminaria" type="luminaria" x="640" y="432">
<point/>
</object>
<object id="58" name="luminaria" type="luminaria" x="768" y="432">
<point/>
</object>
<object id="59" name="luminaria" type="luminaria" x="928" y="432">
<point/>
</object>
<object id="60" name="luminaria" type="luminaria" x="480" y="592">
<point/>
</object>
<object id="61" name="luminaria" type="luminaria" x="640" y="592">
<point/>
</object>
<object id="62" name="luminaria" type="luminaria" x="768" y="592">
<point/>
</object>
<object id="63" name="luminaria" type="luminaria" x="928" y="592">
<point/>
</object>
<object id="64" name="luminaria" type="luminaria" x="1184" y="320">
<point/>
</object>
<object id="65" name="luminaria" type="luminaria" x="1312" y="320">
<point/>
</object>
<object id="66" name="luminaria" type="luminaria" x="1184" y="448">
<point/>
</object>
<object id="67" name="luminaria" type="luminaria" x="1312" y="448">
<point/>
</object>
<object id="68" name="luminaria" type="luminaria" x="1184" y="656">
<point/>
</object>
<object id="69" name="luminaria" type="luminaria" x="1312" y="656">
<point/>
</object>
<object id="70" name="luminaria" type="luminaria" x="112" y="896">
<point/>
</object>
<object id="71" name="luminaria" type="luminaria" x="288" y="896">
<point/>
</object>
<object id="72" name="luminaria" type="luminaria" x="464" y="896">
<point/>
</object>
<object id="73" name="luminaria" type="luminaria" x="672" y="896">
<point/>
</object>
<object id="74" name="luminaria" type="luminaria" x="864" y="896">
<point/>
</object>
<object id="75" name="luminaria" type="luminaria" x="1024" y="896">
<point/>
</object>
<object id="76" name="luminaria" type="luminaria" x="1152" y="896">
<point/>
</object>
<object id="77" name="luminaria" type="luminaria" x="1312" y="896">
<point/>
</object>
<object id="78" name="luminaria" type="luminaria" x="400" y="288">
<point/>
</object>
<object id="79" name="luminaria" type="luminaria" x="544" y="288">
<point/>
</object>
<object id="80" name="luminaria" type="luminaria" x="704" y="288">
<point/>
</object>
<object id="81" name="luminaria" type="luminaria" x="864" y="288">
<point/>
</object>
<object id="82" name="luminaria" type="luminaria" x="1008" y="288">
<point/>
</object>
<object id="83" name="luminaria" type="luminaria" x="400" y="736">
<point/>
</object>
<object id="84" name="luminaria" type="luminaria" x="544" y="736">
<point/>
</object>
<object id="85" name="luminaria" type="luminaria" x="704" y="736">
<point/>
</object>
<object id="86" name="luminaria" type="luminaria" x="864" y="736">
<point/>
</object>
<object id="87" name="luminaria" type="luminaria" x="1008" y="736">
<point/>
</object>
<object id="88" name="luminaria" type="luminaria" x="352" y="416">
<point/>
</object>
<object id="89" name="luminaria" type="luminaria" x="352" y="608">
<point/>
</object>
<object id="90" name="luminaria" type="luminaria" x="1056" y="416">
<point/>
</object>
<object id="91" name="luminaria" type="luminaria" x="1056" y="608">
<point/>
</object>
<object id="92" name="cartaz" type="cartaz" x="240" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="93" name="cartaz" type="cartaz" x="400" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="94" name="cartaz" type="cartaz" x="688" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
<object id="95" name="cartaz" type="cartaz" x="512" y="144">
<properties>
<property name="face" value="L"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
<object id="96" name="cartaz" type="cartaz" x="1168" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="97" name="cartaz" type="cartaz" x="288" y="464">
<properties>
<property name="face" value="O"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="98" name="cartaz" type="cartaz" x="656" y="320">
<properties>
<property name="face" value="N"/>
<property name="imagem" type="int" value="4"/>
</properties>
<point/>
</object>
<object id="99" name="cartaz" type="cartaz" x="320" y="592">
<properties>
<property name="face" value="L"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
<object id="100" name="cartaz" type="cartaz" x="1088" y="464">
<properties>
<property name="face" value="O"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="101" name="cartaz" type="cartaz" x="816" y="704">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="102" name="cartaz" type="cartaz" x="656" y="352">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="103" name="cartaz" type="cartaz" x="416" y="592">
<properties>
<property name="face" value="L"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="104" name="cartaz" type="cartaz" x="368" y="800">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
</objectgroup>
</map>
Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

+458
View File
@@ -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<Texture>();
const materiais = new Set<Material>();
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<typeof THREE.WebGLRenderer>;
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<typeof THREE.Mesh>
).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<HTMLDivElement>(null);
const [rodada, setRodada] = useState(0);
const [estado, setEstado] = useState<Estado>({ 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 (
<div className="fixed inset-0 z-[9500] bg-[#0b0c0e] text-[#f4f2ed]">
<div ref={palco} className="absolute inset-0" />
<div className="absolute inset-x-0 top-0 p-sp-4 font-mono">
<h1 className="text-[20px] font-semibold">Bancada do jogo</h1>
{estado.fase === "carregando" && (
<p className="text-[18px]">Carregando…</p>
)}
{estado.fase === "medindo" && (
<p className="text-[18px]" aria-live="polite">
Medindo… {estado.faltam} s
</p>
)}
</div>
{estado.fase === "pronto" && (
<Resultado
resultado={estado.resultado}
seg={seg}
medirDeNovo={() => setRodada((n) => n + 1)}
/>
)}
</div>
);
}
function Resultado({
resultado,
seg,
medirDeNovo,
}: {
resultado: ResultadoDaBancada;
seg: number;
medirDeNovo: () => void;
}) {
if ("erro" in resultado)
return (
<p className="absolute inset-0 grid place-items-center p-sp-6 text-center text-[24px]">
Este navegador não desenha 3D.
</p>
);
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 (
<div className="absolute inset-0 overflow-auto bg-[rgba(11,12,14,.88)] p-sp-6 pt-[72px] font-mono">
<dl className="grid grid-cols-[auto_1fr] gap-x-sp-4 gap-y-sp-2 text-[clamp(20px,5vw,32px)] leading-tight">
{linhas.map(([nome, valor]) => (
<div key={nome} className="contents">
<dt className="text-[rgba(244,242,237,.6)]">{nome}</dt>
<dd className="break-words">{valor}</dd>
</div>
))}
</dl>
<div className="mt-sp-6 flex flex-wrap gap-sp-3 text-[20px]">
<button
type="button"
onClick={medirDeNovo}
className="rounded-md bg-[#c5f04a] px-sp-4 py-sp-2 font-semibold text-[#0b0c0e]"
>
Medir de novo
</button>
{[8, 16, 24].map((n) => (
<Link
key={n}
href={`/jogo/bancada?zumbis=${n}&seg=${seg}`}
className="rounded-md border border-[#c5f04a] px-sp-4 py-sp-2 text-[#c5f04a]"
>
{n} zumbis
</Link>
))}
</div>
</div>
);
}
+122
View File
@@ -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<Retrato> = {}, mudo = false) {
const acoes = {
aoAlternarMudo: jest.fn(),
aoContinuar: jest.fn(),
aoSair: jest.fn(),
};
render(<Hud retrato={{ ...base, ...parcial }} mudo={mudo} {...acoes} />);
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(
<Hud
retrato={base}
mudo={false}
aoAlternarMudo={jest.fn()}
aoContinuar={jest.fn()}
aoSair={jest.fn()}
/>,
);
expect(container.firstElementChild).toHaveClass("pointer-events-none");
expect(screen.getByRole("button")).toHaveClass("pointer-events-auto");
});
});
+237
View File
@@ -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 (
<svg
aria-hidden="true"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 9v6h4l5 4V5L8 9H4z" />
{mudo ? (
<path d="M17 9l5 6M22 9l-5 6" />
) : (
<path d="M17 8.5a5 5 0 0 1 0 7M19.5 6a8.5 8.5 0 0 1 0 12" />
)}
</svg>
);
}
export default function Hud({
retrato,
mudo,
aoAlternarMudo,
aoContinuar,
aoSair,
}: Props) {
const continuar = useRef<HTMLButtonElement>(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 (
<div className="pointer-events-none absolute inset-0 overflow-hidden font-display">
<div
aria-hidden="true"
className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--slash)] ring-1 ring-black/70"
/>
<div
className="absolute flex items-start justify-between gap-sp-2"
style={{
top: `max(var(--sp-2), ${AREA_TOPO})`,
left: `max(var(--sp-2), ${AREA_ESQUERDA})`,
right: `max(var(--sp-2), ${AREA_DIREITA})`,
}}
>
<p className={`${painel} px-sp-3 py-sp-2 leading-[1.4]`}>
{emJogo ? <Alto retrato={retrato} /> : "Expediente dos Mortos"}
</p>
<div className="flex min-w-0 shrink-0 flex-col items-end gap-sp-2">
<div className="flex items-start gap-sp-2">
<p className={`${painel} px-sp-3 py-sp-2 leading-[1.4]`}>
Você {retrato.placar.jogador} · Drones {retrato.placar.drones}
</p>
<button
type="button"
onClick={aoAlternarMudo}
aria-label={mudo ? "Ligar o som" : "Desligar o som"}
className={`${botao} w-11 px-0`}
>
<IconeSom mudo={mudo} />
</button>
</div>
<ul
aria-live="polite"
aria-label="Últimas eliminações"
style={{ "--feed-max": FEED_ALTURA_MAXIMA } as CSSProperties}
className={`${resolvido ? "opacity-0" : ""} ${fundo} grid w-max max-w-[40vw] gap-sp-1 overflow-hidden px-sp-3 py-sp-2 text-[12px] leading-4 empty:p-0 [&>li]:truncate [&>li:nth-last-child(n+6)]:hidden [@media(max-height:499px)]:[&>li:nth-last-child(n+4)]:hidden [@media(pointer:coarse)]:max-h-[var(--feed-max)]`}
>
{retrato.feed.map((linha) => (
<li key={`${linha.passo}-${linha.texto}`}>{linha.texto}</li>
))}
</ul>
</div>
</div>
{retrato.drones.length > 0 ? (
<ul
aria-label="Drones aliados"
className="absolute left-sp-2 top-20 grid w-36 gap-sp-1"
>
{retrato.drones.map((drone, i) => {
const solucao = problemaPorId(drone.problemaId)?.solucao ?? "";
return (
<li
key={`${drone.problemaId}-${i}`}
className={`${painel} px-sp-2 py-sp-1 text-[12px] leading-[1.3]`}
>
<span className="block">{solucao}</span>
<span className="block">
{drone.eliminacoes}{" "}
{drone.eliminacoes === 1 ? "eliminação" : "eliminações"}
</span>
</li>
);
})}
</ul>
) : null}
<div
className={`${painel} absolute bottom-sp-2 left-sp-2 grid w-48 gap-sp-1 px-sp-3 py-sp-2`}
>
<div className="flex items-center gap-sp-2">
<span>Vida</span>
<div
role="progressbar"
aria-label="Vida"
aria-valuemin={0}
aria-valuemax={JOGADOR.vida}
aria-valuenow={vida}
className="h-2 flex-1 bg-white/25"
>
<div
className="h-full bg-[var(--slash)]"
style={{ width: `${(vida / JOGADOR.vida) * 100}%` }}
/>
</div>
<span>{vida}</span>
</div>
<p>
{arma.nome}
{municao !== null ? ` · ${municao}` : ""}
</p>
</div>
{resolvido ? (
<div
className="absolute inset-x-0 flex -translate-y-1/2 justify-center"
style={{ top: AVISO_CENTRO }}
>
<div className="w-80 max-w-[calc(100%-2rem)] bg-[var(--ink)] p-sp-4 text-[#f4f2ed] ring-1 ring-[var(--slash)] [@media(max-height:499px)]:w-56 [@media(max-height:499px)]:p-sp-3">
<p className="text-[20px] font-semibold leading-[1.15] tracking-[-0.02em] [@media(max-height:499px)]:text-[16px]">
Resolvido: {resolvido.nome}
</p>
<p className="mt-sp-2 text-[15px] leading-[1.4] text-[rgba(244,242,237,.8)] [@media(max-height:499px)]:text-[13px]">
{resolvido.solucao} entrou para o seu time
</p>
</div>
</div>
) : null}
{retrato.pausado ? (
<div
role="dialog"
aria-modal="true"
aria-labelledby="hud-pausa"
className="pointer-events-auto absolute inset-0 flex items-center justify-center bg-black/70 p-sp-4"
>
<div className="w-full max-w-md bg-[var(--ink)] p-sp-6 text-[#f4f2ed] ring-1 ring-white/25">
<h2
id="hud-pausa"
className="text-[28px] font-semibold leading-none tracking-[-0.02em]"
>
Pausado
</h2>
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
<button
ref={continuar}
type="button"
onClick={aoContinuar}
className={`${botao} bg-[var(--slash)] text-[var(--ink)] hover:bg-[var(--paper)]`}
>
Continuar
</button>
<button
type="button"
onClick={aoAlternarMudo}
aria-pressed={!mudo}
className={botao}
>
{mudo ? "Som desligado" : "Som ligado"}
</button>
<button type="button" onClick={aoSair} className={botao}>
Sair
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PROBLEMAS } from "@/lib/jogo/problemas";
import Inicio from "./Inicio";
function montar(extra: Partial<Parameters<typeof Inicio>[0]> = {}) {
const aoComecar = jest.fn();
render(
<Inicio
aoComecar={aoComecar}
toque={false}
movimentoReduzido={false}
semWebgl={false}
{...extra}
/>,
);
return aoComecar;
}
describe("Inicio", () => {
it("mostra o nome, a frase e o aviso", () => {
montar();
expect(
screen.getByRole("heading", { level: 1, name: "Expediente dos Mortos" }),
).toBeInTheDocument();
expect(
screen.getByText("Os problemas da sua empresa não morrem sozinhos."),
).toBeInTheDocument();
expect(
screen.getByText("Tem sangue e sustos. É um jogo de tiro."),
).toBeInTheDocument();
});
it("deixa Começar desabilitado, com o motivo, até escolher o problema", async () => {
const aoComecar = montar();
const botao = screen.getByRole("button", { name: "Começar" });
expect(botao).toBeDisabled();
expect(botao).toHaveAccessibleDescription(
"Escolha o problema do chefão para começar.",
);
expect(
screen
.getAllByRole("radio", { checked: true })
.map((r) => r.getAttribute("name")),
).toEqual(["dificuldade"]);
await userEvent.click(botao);
expect(aoComecar).not.toHaveBeenCalled();
});
it("passa o problema e a dificuldade ao começar (Fácil por padrão)", async () => {
const aoComecar = montar();
const alvo = PROBLEMAS[3];
await userEvent.click(screen.getByRole("radio", { name: alvo.nome }));
await userEvent.click(screen.getByRole("button", { name: "Começar" }));
expect(aoComecar).toHaveBeenCalledWith({
problemaId: alvo.id,
dificuldade: "facil",
});
});
it("envia a dificuldade escolhida", async () => {
const aoComecar = montar();
await userEvent.click(screen.getByRole("radio", { name: /Difícil/ }));
await userEvent.click(
screen.getByRole("radio", { name: PROBLEMAS[0].nome }),
);
await userEvent.click(screen.getByRole("button", { name: "Começar" }));
expect(aoComecar).toHaveBeenCalledWith({
problemaId: PROBLEMAS[0].id,
dificuldade: "dificil",
});
});
it("explica os controles de teclado e mouse ou os de toque", () => {
const { unmount } = render(
<Inicio
aoComecar={jest.fn()}
toque={false}
movimentoReduzido={false}
semWebgl={false}
/>,
);
expect(screen.getByText(/W A S D/)).toBeInTheDocument();
unmount();
montar({ toque: true });
expect(screen.getByText(/polegar esquerdo anda/i)).toBeInTheDocument();
expect(screen.queryByText(/W A S D/)).not.toBeInTheDocument();
});
it("sem WebGL troca o botão pelo aviso", () => {
montar({ semWebgl: true });
expect(
screen.queryByRole("button", { name: /Começar|Jogar mesmo assim/ }),
).not.toBeInTheDocument();
expect(
screen.getByText(
"Este navegador não desenha 3D. Veja os problemas e as soluções abaixo.",
),
).toBeInTheDocument();
});
it("com movimento reduzido avisa e o botão vira Jogar mesmo assim", () => {
montar({ movimentoReduzido: true });
expect(screen.getByText(/movimento intenso/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Jogar mesmo assim" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Começar" }),
).not.toBeInTheDocument();
});
it("mostra o resumo da escolha ao lado do Começar", async () => {
montar();
const alvo = PROBLEMAS[2];
await userEvent.click(screen.getByRole("radio", { name: /Normal/ }));
await userEvent.click(screen.getByRole("radio", { name: alvo.nome }));
expect(
screen.getByText(`${alvo.nome} · Normal`, { exact: false }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Começar" })).toBeEnabled();
});
it("dá a cada dificuldade a sua explicação", () => {
montar();
expect(
screen.getByRole("radio", { name: "Difícil" }),
).toHaveAccessibleDescription(
"Zumbis mais rápidos, mais numerosos e que machucam mais.",
);
});
it("abre e fecha as áreas da sanfona, com a primeira aberta", async () => {
montar();
const primeira = screen.getByRole("button", {
name: "Vender e ser encontrado",
});
const segunda = screen.getByRole("button", { name: "Atender" });
expect(primeira).toHaveAttribute("aria-expanded", "true");
expect(segunda).toHaveAttribute("aria-expanded", "false");
await userEvent.click(segunda);
expect(segunda).toHaveAttribute("aria-expanded", "true");
await userEvent.click(primeira);
expect(primeira).toHaveAttribute("aria-expanded", "false");
});
it("usa a capa como imagem decorativa", () => {
const { container } = render(
<Inicio
aoComecar={jest.fn()}
toque={false}
movimentoReduzido={false}
semWebgl={false}
/>,
);
const capa = container.querySelector("img");
expect(capa).toHaveAttribute("alt", "");
expect(capa?.getAttribute("src")).toContain("capa.jpg");
});
});
+289
View File
@@ -0,0 +1,289 @@
"use client";
import Image from "next/image";
import { useId, useState } from "react";
import { pageTop, primaryButton } from "@/app/components/button-styles";
import {
AREAS,
type AreaId,
problemaPorId,
problemasDaArea,
} from "@/lib/jogo/problemas";
import type { Dificuldade } from "@/lib/jogo/tipos";
import type { Escolha } from "./contrato";
type Props = {
aoComecar(escolha: Escolha): void;
toque: boolean;
movimentoReduzido: boolean;
semWebgl: boolean;
};
const ORDEM = Object.keys(AREAS) as AreaId[];
const NIVEIS: readonly { id: Dificuldade; nome: string; linha: string }[] = [
{
id: "facil",
nome: "Fácil",
linha: "Zumbis lentos, poucos e de dano baixo. Bom para a primeira vez.",
},
{
id: "normal",
nome: "Normal",
linha: "Zumbis em velocidade, quantidade e dano normais.",
},
{
id: "dificil",
nome: "Difícil",
linha: "Zumbis mais rápidos, mais numerosos e que machucam mais.",
},
];
// Botão de opção com cara de ficha: o rádio fica só para leitor de tela e teclado; o quadrado cheio marca a escolha
// sem depender só da cor.
const ficha =
"group flex min-h-11 cursor-pointer items-center gap-sp-3 border border-white/20 bg-[rgba(10,10,10,.86)] px-sp-3 py-sp-2 text-[15px] leading-[1.3] transition-colors duration-200 hover:border-white/50 has-[:checked]:border-[var(--slash)] has-[:checked]:bg-[var(--slash-dim)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--slash)] motion-reduce:transition-none";
function Marca() {
return (
<span
aria-hidden="true"
className="size-2.5 shrink-0 border border-white/50 transition-colors duration-200 group-has-[:checked]:border-[var(--slash)] group-has-[:checked]:bg-[var(--slash)] motion-reduce:transition-none"
/>
);
}
const rotulo = "text-[15px] font-semibold leading-[1.3]";
const secundario = "text-[rgba(244,242,237,.72)]";
export default function Inicio({
aoComecar,
toque,
movimentoReduzido,
semWebgl,
}: Props) {
const [problemaId, setProblemaId] = useState<string | null>(null);
const [dificuldade, setDificuldade] = useState<Dificuldade>("facil");
// sanfona do celular; no computador todas as áreas ficam abertas
const [abertas, setAbertas] = useState<readonly AreaId[]>([ORDEM[0]]);
const [capaPronta, setCapaPronta] = useState(false);
const base = useId();
const motivo = `${base}-motivo`;
const escolhido = problemaId ? problemaPorId(problemaId) : undefined;
const nivel = NIVEIS.find((n) => n.id === dificuldade) ?? NIVEIS[0];
return (
<section
aria-labelledby="jogo-titulo"
className={`relative isolate bg-[var(--ink)] px-sp-6 pb-sp-16 pt-[calc(var(--site-header-height)+var(--sp-12))] font-display text-[#f4f2ed] lg:px-sp-12 ${pageTop}`}
>
{/* Capa decorativa (public/jogo/capa.jpg), escurecida até --ink. Entra com fade a partir de opacidade zero, como o
pôster do hero: o título continua o maior elemento pintado primeiro (LCP), e sem o arquivo a faixa fica só escura. */}
<div
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 -z-10 h-[340px] w-full lg:h-[520px] lg:w-[45%]"
>
<Image
src="/jogo/capa.jpg"
alt=""
fill
sizes="100vw"
loading="eager"
onLoad={() => setCapaPronta(true)}
className={`object-cover transition-opacity duration-700 ease-[var(--ease-out)] motion-reduce:transition-none ${capaPronta ? "opacity-100" : "opacity-0"}`}
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(10,10,10,.55)_0%,rgba(10,10,10,.7)_45%,var(--ink)_95%)] lg:bg-[linear-gradient(180deg,rgba(10,10,10,0)_40%,var(--ink)_95%),linear-gradient(90deg,rgba(10,10,10,.2)_0%,var(--ink)_92%)]" />
</div>
<form
className="ds-section-shell grid gap-sp-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:grid-rows-[auto_1fr] lg:gap-x-sp-16 lg:gap-y-sp-8"
onSubmit={(evento) => {
evento.preventDefault();
if (problemaId) aoComecar({ problemaId, dificuldade });
}}
>
<div className="lg:col-start-1 lg:row-start-1">
<h1
id="jogo-titulo"
className="text-[clamp(40px,8vw,64px)] font-semibold leading-[0.95] tracking-[-0.04em] text-balance xl:text-[72px]"
>
Expediente dos Mortos
</h1>
<p className="mt-sp-4 text-[clamp(20px,2.6vw,26px)] font-medium leading-[1.2] tracking-[-0.02em] text-[var(--slash)]">
Os problemas da sua empresa não morrem sozinhos.
</p>
<p
className={`mt-sp-5 max-w-[34em] text-[17px] leading-[1.55] ${secundario}`}
>
Cada horda de zumbis é um problema de empresa. Quando você resolve
um, ele vira um drone aliado com o nome da solução. No fim vem o
chefão: o problema que você escolher.
</p>
</div>
<fieldset className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:pt-sp-2">
<legend className="mb-sp-4 text-[20px] font-semibold leading-[1.2] lg:mb-sp-6">
Qual problema é o chefão?
</legend>
<div className="grid lg:gap-sp-6">
{ORDEM.map((area) => {
const aberta = abertas.includes(area);
const painel = `${base}-${area}`;
return (
<fieldset
key={area}
className="min-w-0 border-t border-white/15 last:border-b lg:border-0 lg:last:border-0"
>
{/* no celular o nome da área é o botão da sanfona; a legenda fica para o leitor de tela */}
<legend
className={`max-lg:sr-only lg:mb-sp-3 ${rotulo} ${secundario}`}
>
{AREAS[area]}
</legend>
<button
type="button"
aria-expanded={aberta}
aria-controls={painel}
onClick={() =>
setAbertas((atual) =>
aberta
? atual.filter((a) => a !== area)
: [...atual, area],
)
}
className="flex min-h-13 w-full cursor-pointer items-center justify-between gap-sp-4 py-sp-3 text-left text-[17px] font-semibold leading-[1.3] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] lg:hidden"
>
{AREAS[area]}
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className={`size-4 shrink-0 ${aberta ? "rotate-180" : ""}`}
>
<path
d="M3 6l5 5 5-5"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
/>
</svg>
</button>
<div
id={painel}
className={`flex flex-wrap gap-sp-2 pb-sp-5 lg:grid lg:grid-cols-2 lg:pb-0 xl:grid-cols-3 ${aberta ? "" : "max-lg:hidden"}`}
>
{problemasDaArea(area).map((problema) => (
<label key={problema.id} className={ficha}>
<input
type="radio"
name="problema"
value={problema.id}
checked={problemaId === problema.id}
onChange={() => setProblemaId(problema.id)}
className="sr-only"
/>
<Marca />
{problema.nome}
</label>
))}
</div>
</fieldset>
);
})}
</div>
</fieldset>
{/* No computador, a coluna da esquerda fica presa no topo ao rolar; no celular este invólucro some (contents)
e a barra do "Começar" vira filha direta do formulário, para grudar no pé da tela enquanto a escolha rola. */}
<div className="grid gap-sp-8 max-lg:contents lg:sticky lg:top-[calc(var(--site-header-height)+var(--sp-6))] lg:col-start-1 lg:row-start-2 lg:self-start">
<div className="grid gap-sp-6">
<fieldset className="min-w-0">
<legend className={`mb-sp-3 ${rotulo}`}>Dificuldade</legend>
<div className="grid grid-cols-3 gap-sp-2">
{NIVEIS.map((n) => (
<label key={n.id} className={`${ficha} justify-center`}>
<input
type="radio"
name="dificuldade"
value={n.id}
checked={dificuldade === n.id}
onChange={() => setDificuldade(n.id)}
aria-describedby={`${base}-${n.id}`}
className="sr-only"
/>
<Marca />
<span className="font-semibold">{n.nome}</span>
</label>
))}
</div>
{NIVEIS.map((n) => (
<p
key={n.id}
id={`${base}-${n.id}`}
hidden={dificuldade !== n.id}
className={`mt-sp-2 text-[15px] leading-[1.45] ${secundario}`}
>
{n.linha}
</p>
))}
</fieldset>
<div className="grid gap-sp-2">
<h2 className={rotulo}>Como jogar</h2>
<p
className={`max-w-[38em] text-[15px] leading-[1.5] ${secundario}`}
>
{toque
? "O polegar esquerdo anda. O direito mira. A arma atira sozinha quando a mira está no zumbi."
: "W A S D andam. O mouse mira e o clique atira. As teclas 1, 2 e 3 trocam de arma. Esc pausa."}
</p>
<p className="text-[15px] font-medium leading-[1.5]">
Tem sangue e sustos. É um jogo de tiro.
</p>
</div>
{semWebgl ? (
<p className="max-w-[34em] text-[17px] font-medium leading-[1.55]">
Este navegador não desenha 3D. Veja os problemas e as soluções
abaixo.
</p>
) : movimentoReduzido ? (
<p className="max-w-[34em] text-[15px] leading-[1.5]">
O jogo tem movimento intenso: zumbis correndo e a imagem sempre
em mudança.
</p>
) : null}
</div>
{semWebgl ? null : (
// celular: barra presa no pé (acima da barra de cookies) e com a direita livre para o botão do WhatsApp,
// que abaixo de 768 px flutua ali quando a barra de cookies está fechada
<div className="z-10 flex flex-col-reverse gap-sp-2 max-lg:sticky max-lg:bottom-[var(--cookie-bar-height,0px)] max-lg:-mx-sp-6 max-lg:border-t max-lg:border-white/15 max-lg:bg-[var(--ink)] max-lg:px-sp-6 max-lg:pt-sp-3 max-lg:pb-[max(var(--sp-3),env(safe-area-inset-bottom))] max-md:pr-[calc(var(--sp-4)+56px+var(--sp-4))] sm:flex-row-reverse sm:items-center sm:gap-sp-4 max-lg:[[data-cookie-bar=open]_&]:pr-sp-6 max-lg:[[data-cookie-bar=open]_&]:pb-sp-3 lg:flex-row">
<button
type="submit"
disabled={problemaId === null}
aria-describedby={problemaId === null ? motivo : undefined}
className={`${primaryButton} shrink-0 disabled:cursor-not-allowed disabled:bg-white/15 disabled:text-[rgba(244,242,237,.72)] disabled:before:hidden`}
>
{movimentoReduzido ? "Jogar mesmo assim" : "Começar"}
</button>
{escolhido ? (
<p className="min-w-0 flex-1 text-[15px] leading-[1.35]">
<span className={secundario}>Chefão:</span>{" "}
{`${escolhido.nome} · ${nivel.nome}`}
</p>
) : (
<p
id={motivo}
className={`min-w-0 flex-1 text-[15px] leading-[1.35] ${secundario}`}
>
Escolha o problema do chefão para começar.
</p>
)}
</div>
)}
</div>
</form>
</section>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PROBLEMAS } from "@/lib/jogo/problemas";
import type { Cena, OpcoesDaCena } from "./cena";
import type { Resultado, Retrato } from "./contrato";
import Jogo from "./Jogo";
// a cena e o som são escritos em paralelo: virtual, para os dublês valerem mesmo antes de os arquivos existirem
jest.mock("./cena", () => ({ criarCena: jest.fn() }), { virtual: true });
jest.mock(
"./som",
() => ({
criarSom: jest.fn(() => ({
liberar: jest.fn(() => Promise.resolve()),
tocar: jest.fn(),
definirMudo: jest.fn(),
descartar: jest.fn(),
})),
}),
{ virtual: true },
);
const { criarCena } = jest.requireMock("./cena") as {
criarCena: jest.Mock<Promise<Cena>, [OpcoesDaCena]>;
};
const { criarSom } = jest.requireMock("./som") as { criarSom: jest.Mock };
const problema = PROBLEMAS[0];
const retrato: Retrato = {
fase: "horda",
horda: 0,
problemaAtual: problema.id,
restantes: 12,
vida: 100,
arma: 0,
municao: 0,
placar: { jogador: 3, drones: 1 },
feed: [],
drones: [],
pausado: false,
};
const resultado: Resultado = {
vitoria: true,
problemaId: problema.id,
dificuldade: "facil",
resolvidos: [problema.id],
placar: { jogador: 40, drones: 9 },
drones: [],
segundos: 300,
};
let cenas: { pausar: jest.Mock; continuar: jest.Mock; descartar: jest.Mock }[];
let midias: Record<string, boolean>;
function opcoes(i = 0) {
return criarCena.mock.calls[i][0];
}
function som() {
return criarSom.mock.results[0].value;
}
async function comecar() {
const usuario = userEvent.setup();
await usuario.click(screen.getByRole("radio", { name: problema.nome }));
await usuario.click(screen.getByRole("button", { name: "Começar" }));
await waitFor(() => expect(criarCena).toHaveBeenCalled());
return usuario;
}
beforeEach(() => {
cenas = [];
midias = {};
criarCena.mockReset().mockImplementation(async () => {
const cena = {
pausar: jest.fn(),
continuar: jest.fn(),
descartar: jest.fn(),
};
cenas.push(cena);
return cena;
});
criarSom.mockClear();
window.matchMedia = jest.fn((query: string) => ({
matches: midias[query] ?? false,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})) as unknown as typeof window.matchMedia;
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue({ getExtension: () => null } as never);
localStorage.clear();
window.history.replaceState(null, "", "/jogo");
});
afterEach(() => jest.restoreAllMocks());
describe("Jogo", () => {
it("começa pela tela inicial, sem carregar a cena", () => {
render(<Jogo />);
expect(
screen.getByRole("heading", { level: 1, name: "Expediente dos Mortos" }),
).toBeInTheDocument();
expect(criarCena).not.toHaveBeenCalled();
});
it("ao começar libera o som e cria a cena com a escolha", async () => {
render(<Jogo />);
await comecar();
expect(som().liberar).toHaveBeenCalled();
expect(opcoes()).toMatchObject({
escolha: { problemaId: problema.id, dificuldade: "facil" },
piloto: false,
movimentoReduzido: false,
});
expect(opcoes().canvas).toBeInstanceOf(HTMLCanvasElement);
expect(document.documentElement.style.overflow).toBe("hidden");
});
it("mostra no HUD o retrato que a cena manda", async () => {
render(<Jogo />);
await comecar();
act(() => opcoes().aoRetrato(retrato));
expect(await screen.findByText("Você 3 · Drones 1")).toBeInTheDocument();
});
it("ao terminar mostra o relatório, descarta a cena e destrava a rolagem", async () => {
render(<Jogo />);
await comecar();
await waitFor(() => expect(cenas).toHaveLength(1));
act(() => opcoes().aoTerminar(resultado));
expect(
screen.getByRole("heading", { level: 1, name: "Problemas resolvidos" }),
).toBeInTheDocument();
expect(cenas[0].descartar).toHaveBeenCalled();
expect(document.documentElement.style.overflow).toBe("");
});
it("jogar de novo cria outra cena com a mesma escolha e outra semente", async () => {
render(<Jogo />);
const usuario = await comecar();
await waitFor(() => expect(cenas).toHaveLength(1));
act(() => opcoes().aoTerminar(resultado));
await usuario.click(screen.getByRole("button", { name: "Jogar de novo" }));
await waitFor(() => expect(criarCena).toHaveBeenCalledTimes(2));
expect(opcoes(1).escolha).toEqual(opcoes(0).escolha);
expect(opcoes(1).semente).not.toBe(opcoes(0).semente);
});
it("ao desmontar descarta a cena e o som", async () => {
const { unmount } = render(<Jogo />);
await comecar();
await waitFor(() => expect(cenas).toHaveLength(1));
unmount();
expect(cenas[0].descartar).toHaveBeenCalled();
expect(som().descartar).toHaveBeenCalled();
expect(document.documentElement.style.overflow).toBe("");
});
it("se a cena falhar, volta ao início com a mensagem", async () => {
criarCena.mockRejectedValueOnce(new Error("sem webgl2"));
render(<Jogo />);
await comecar();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Não deu para abrir o jogo neste navegador.",
);
expect(
screen.getByRole("heading", { level: 1, name: "Expediente dos Mortos" }),
).toBeInTheDocument();
});
it("com piloto=1 e um problema válido pula o início", async () => {
window.history.replaceState(
null,
"",
`/jogo?piloto=1&semente=7&problema=${problema.id}&dificuldade=normal&vel=8`,
);
render(<Jogo />);
await waitFor(() => expect(criarCena).toHaveBeenCalled());
expect(opcoes()).toMatchObject({
escolha: { problemaId: problema.id, dificuldade: "normal" },
semente: 7,
piloto: true,
velocidade: 8,
});
expect(
screen.queryByRole("heading", { name: "Expediente dos Mortos" }),
).not.toBeInTheDocument();
});
it("no toque, em retrato, pede para virar o aparelho e pausa", async () => {
midias["(pointer: coarse)"] = true;
midias["(orientation: portrait)"] = true;
render(<Jogo />);
await comecar();
expect(
await screen.findByText("Vire o aparelho para jogar"),
).toBeInTheDocument();
expect(cenas[0].pausar).toHaveBeenCalled();
});
it("continuar depois da pausa libera o som de novo, no mesmo clique", async () => {
render(<Jogo />);
const usuario = await comecar();
await waitFor(() => expect(cenas).toHaveLength(1));
act(() => opcoes().aoRetrato({ ...retrato, pausado: true }));
som().liberar.mockClear();
await usuario.click(
await screen.findByRole("button", { name: "Continuar" }),
);
expect(som().liberar).toHaveBeenCalledTimes(1);
expect(cenas[0].continuar).toHaveBeenCalled();
});
it("a cena sabe que foi cancelada quando o jogador sai durante a carga", async () => {
const { unmount } = render(<Jogo />);
await comecar();
expect(opcoes().cancelada()).toBe(false);
unmount();
expect(opcoes().cancelada()).toBe(true);
});
});
+325
View File
@@ -0,0 +1,325 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { problemaPorId } from "@/lib/jogo/problemas";
import type { Dificuldade } from "@/lib/jogo/tipos";
import type { Cena } from "./cena";
import type { Controle, Escolha, Resultado, Retrato } from "./contrato";
import { criarControle } from "./controle";
import Hud from "./Hud";
import Inicio from "./Inicio";
import Relatorio from "./Relatorio";
import { criarSom, type Som } from "./som";
import { Toque } from "./Toque";
const ERRO = "Não deu para abrir o jogo neste navegador.";
const CHAVE_MUDO = "jogo-mudo";
const DIFICULDADES: readonly Dificuldade[] = ["facil", "normal", "dificil"];
type Ambiente = {
toque: boolean;
movimentoReduzido: boolean;
semWebgl: boolean;
};
/** Parâmetros da URL, só para teste e medição. */
type Teste = {
piloto: boolean;
velocidade?: number;
semente?: number;
escolha?: Escolha;
};
/** Tudo o que a cena recebe além do canvas, do controle, do som e dos avisos. */
type Partida = {
escolha: Escolha;
semente: number;
movimentoReduzido: boolean;
piloto: boolean;
velocidade?: number;
};
type Tela =
| { etapa: "inicio"; erro?: string }
| { etapa: "carregando" | "jogando"; partida: Partida }
| { etapa: "fim"; resultado: Resultado };
function lerUrl(busca: string): Teste {
const p = new URLSearchParams(busca);
const semente = Number.parseInt(p.get("semente") ?? "", 10);
const velocidade = Number(p.get("vel"));
const problemaId = p.get("problema") ?? "";
const dificuldade = DIFICULDADES.find((d) => d === p.get("dificuldade"));
return {
piloto: p.get("piloto") === "1",
semente: Number.isFinite(semente) ? semente : undefined,
velocidade: velocidade > 0 ? velocidade : undefined,
escolha: problemaPorId(problemaId)
? { problemaId, dificuldade: dificuldade ?? "facil" }
: undefined,
};
}
function temWebgl() {
const contexto = document.createElement("canvas").getContext("webgl2");
contexto?.getExtension("WEBGL_lose_context")?.loseContext();
return contexto !== null;
}
/** Tela cheia e paisagem no toque; cada navegador aceita uma parte, então as duas falham em silêncio. */
async function ocuparTela() {
try {
await document.documentElement.requestFullscreen();
} catch {}
try {
// `lock` saiu dos tipos do DOM do TypeScript, mas existe nos navegadores de celular
const orientacao = screen.orientation as ScreenOrientation & {
lock?(tipo: "landscape"): Promise<void>;
};
await orientacao.lock?.("landscape");
} catch {}
}
export default function Jogo() {
const [tela, setTela] = useState<Tela>({ etapa: "inicio" });
const [ambiente, setAmbiente] = useState<Ambiente>({
toque: false,
movimentoReduzido: false,
semWebgl: false,
});
const [retrato, setRetrato] = useState<Retrato | null>(null);
const [mirando, setMirando] = useState(false);
const [mudo, setMudo] = useState(false);
const [virar, setVirar] = useState(false);
const canvas = useRef<HTMLCanvasElement>(null);
const som = useRef<Som | null>(null);
const cena = useRef<Cena | null>(null);
const controle = useRef<Controle | null>(null);
const teste = useRef<Teste>({ piloto: false });
const partida =
tela.etapa === "carregando" || tela.etapa === "jogando"
? tela.partida
: null;
function pausar() {
cena.current?.pausar();
controle.current?.soltar();
}
function comecar(
escolha: Escolha,
amb: Ambiente,
semente = Math.floor(Math.random() * 2 ** 32),
) {
som.current?.liberar().catch(() => {});
if (amb.toque) void ocuparTela();
setRetrato(null);
setMirando(false);
setTela({
etapa: "carregando",
partida: {
escolha,
semente,
movimentoReduzido: amb.movimentoReduzido,
piloto: teste.current.piloto,
velocidade: teste.current.velocidade,
},
});
}
// biome-ignore lint/correctness/useExhaustiveDependencies: roda só ao montar; `comecar` só lê refs e o ambiente passado
useEffect(() => {
const amb: Ambiente = {
toque: matchMedia("(pointer: coarse)").matches,
movimentoReduzido: matchMedia("(prefers-reduced-motion: reduce)").matches,
semWebgl: !temWebgl(),
};
setAmbiente(amb);
setMudo(localStorage.getItem(CHAVE_MUDO) === "1");
som.current = criarSom();
teste.current = lerUrl(window.location.search);
const { piloto, escolha, semente } = teste.current;
if (piloto && escolha) comecar(escolha, amb, semente);
return () => {
som.current?.descartar();
som.current = null;
};
}, []);
useEffect(() => {
som.current?.definirMudo(mudo);
}, [mudo]);
// Uma partida: importa a cena (que traz o three) e a cria. Sair, terminar, falhar ou desmontar descarta tudo.
// biome-ignore lint/correctness/useExhaustiveDependencies: `pausar` só lê refs; a partida é a única dependência
useEffect(() => {
const alvo = canvas.current;
if (!partida || !alvo) return;
let viva = true;
controle.current = criarControle(alvo, {
aoMudarTrava: (travado) => {
if (!travado) pausar();
},
});
(async () => {
try {
// sob demanda: a cena traz o three, que não entra no carregamento inicial da página
const { criarCena } = await import("./cena");
if (!viva || !controle.current || !som.current) return;
const nova = await criarCena({
canvas: alvo,
escolha: partida.escolha,
semente: partida.semente,
controle: controle.current,
som: som.current,
movimentoReduzido: partida.movimentoReduzido,
piloto: partida.piloto,
velocidade: partida.velocidade,
cancelada: () => !viva,
aoRetrato: (novo) => {
if (viva) setRetrato(novo);
},
aoMirar: (sim) => {
if (viva) setMirando(sim);
},
aoTerminar: (resultado) => {
if (viva) setTela({ etapa: "fim", resultado });
},
});
if (!viva) {
nova.descartar();
return;
}
cena.current = nova;
setTela({ etapa: "jogando", partida });
} catch {
if (viva) setTela({ etapa: "inicio", erro: ERRO });
}
})();
return () => {
viva = false;
cena.current?.descartar();
cena.current = null;
controle.current?.descartar();
controle.current = null;
if (document.pointerLockElement) document.exitPointerLock();
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
};
}, [partida]);
// A página não rola por baixo da camada do jogo.
const emPartida = partida !== null;
useEffect(() => {
if (!emPartida) return;
const raiz = document.documentElement;
const antes = raiz.style.overflow;
raiz.style.overflow = "hidden";
return () => {
raiz.style.overflow = antes;
};
}, [emPartida]);
// Sair da aba pausa; no toque, o aparelho em retrato também.
const jogando = tela.etapa === "jogando";
const toque = ambiente.toque;
// biome-ignore lint/correctness/useExhaustiveDependencies: `pausar` só lê refs
useEffect(() => {
if (!jogando) return;
const aoTrocarAba = () => {
if (document.hidden) pausar();
};
document.addEventListener("visibilitychange", aoTrocarAba);
const retratoTela = toque ? matchMedia("(orientation: portrait)") : null;
const aoGirar = () => {
const deitado = retratoTela?.matches ?? false;
setVirar(deitado);
if (deitado) pausar();
};
aoGirar();
retratoTela?.addEventListener("change", aoGirar);
return () => {
document.removeEventListener("visibilitychange", aoTrocarAba);
retratoTela?.removeEventListener("change", aoGirar);
setVirar(false);
};
}, [jogando, toque]);
function alternarMudo() {
const novo = !mudo;
localStorage.setItem(CHAVE_MUDO, novo ? "1" : "0");
setMudo(novo);
}
if (tela.etapa === "fim") {
const { problemaId, dificuldade } = tela.resultado;
return (
<Relatorio
resultado={tela.resultado}
aoJogarDeNovo={() => comecar({ problemaId, dificuldade }, ambiente)}
/>
);
}
if (tela.etapa === "inicio") {
return (
<>
<Inicio
aoComecar={(escolha) => comecar(escolha, ambiente)}
toque={ambiente.toque}
movimentoReduzido={ambiente.movimentoReduzido}
semWebgl={ambiente.semWebgl}
/>
{tela.erro ? (
<p
role="alert"
className="bg-[var(--ink)] px-sp-6 pb-sp-10 font-display text-[17px] font-medium leading-[1.55] text-[#f4f2ed] lg:px-sp-12"
>
<span className="ds-section-shell block">{tela.erro}</span>
</p>
) : null}
</>
);
}
return (
<div className="fixed inset-0 z-[10000] overflow-hidden bg-[var(--ink)] font-display text-[#f4f2ed]">
<canvas ref={canvas} className="block h-full w-full" />
{tela.etapa === "carregando" ? (
<output className="absolute inset-0 flex items-center justify-center text-[20px] font-medium">
Carregando o escritório…
</output>
) : null}
{jogando && toque && controle.current && retrato ? (
<Toque
controle={controle.current}
arma={retrato.arma}
municao={retrato.municao}
mirando={mirando}
/>
) : null}
{jogando && retrato ? (
<Hud
retrato={retrato}
mudo={mudo}
aoAlternarMudo={alternarMudo}
aoContinuar={() => {
// dentro do gesto: no iOS o som só volta assim depois de sair da aba
som.current?.liberar().catch(() => {});
cena.current?.continuar();
if (!toque) canvas.current?.requestPointerLock?.();
}}
aoSair={() => setTela({ etapa: "inicio" })}
/>
) : null}
{virar ? (
<p
role="alert"
className="absolute inset-0 flex items-center justify-center bg-[var(--ink)] p-sp-6 text-center text-[24px] font-semibold leading-[1.2]"
>
Vire o aparelho para jogar
</p>
) : null}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { render, screen, within } from "@testing-library/react";
import { AREAS, PROBLEMAS } from "@/lib/jogo/problemas";
import ListaDeProblemas from "./ListaDeProblemas";
describe("ListaDeProblemas", () => {
it("mostra os 27 problemas, cada um com a solução como link para o destino", () => {
render(<ListaDeProblemas />);
expect(PROBLEMAS).toHaveLength(27);
expect(screen.getAllByRole("listitem")).toHaveLength(27);
for (const problema of PROBLEMAS) {
expect(screen.getByText(problema.nome)).toBeInTheDocument();
const link = screen
.getAllByRole("link", { name: problema.solucao })
.find((a) => a.getAttribute("href") === problema.destino);
expect(link).toBeDefined();
}
});
it("agrupa pelas áreas", () => {
render(<ListaDeProblemas />);
for (const nome of Object.values(AREAS)) {
expect(
screen.getByRole("heading", { level: 3, name: nome }),
).toBeInTheDocument();
}
const primeira = screen.getByRole("heading", {
level: 3,
name: AREAS.vender,
}).parentElement as HTMLElement;
expect(within(primeira).getAllByRole("listitem").length).toBeGreaterThan(0);
});
});
+46
View File
@@ -0,0 +1,46 @@
import Link from "next/link";
import { textLinkDark } from "@/app/components/button-styles";
import { AREAS, type AreaId, problemasDaArea } from "@/lib/jogo/problemas";
const ORDEM = Object.keys(AREAS) as AreaId[];
/** Os 27 problemas e as soluções, em texto. É o que vê quem está sem JavaScript, sem WebGL ou com movimento reduzido. */
export default function ListaDeProblemas() {
return (
<section
aria-labelledby="lista-de-problemas"
className="bg-[var(--ink)] px-sp-6 py-sp-16 font-display text-[#f4f2ed] lg:px-sp-12"
>
<div className="ds-section-shell">
<h2
id="lista-de-problemas"
className="text-[clamp(24px,3.4vw,34px)] font-semibold leading-[1.15] tracking-[-0.02em]"
>
Os problemas e as soluções
</h2>
<div className="mt-sp-8 grid gap-sp-10 md:grid-cols-2">
{ORDEM.map((area) => (
<div key={area}>
<h3 className="text-[20px] font-semibold leading-[1.2] tracking-[-0.01em]">
{AREAS[area]}
</h3>
<ul className="mt-sp-3 grid gap-sp-3">
{problemasDaArea(area).map((problema) => (
<li key={problema.id} className="text-[16px] leading-[1.5]">
<span className="text-[rgba(244,242,237,.72)]">
{problema.nome}
</span>
{" → "}
<Link href={problema.destino} className={textLinkDark}>
{problema.solucao}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</section>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PROBLEMAS, problemaPorId } from "@/lib/jogo/problemas";
import type { Resultado } from "./contrato";
import Relatorio from "./Relatorio";
const [a, b, c] = PROBLEMAS;
const base: Resultado = {
vitoria: true,
problemaId: c.id,
dificuldade: "facil",
resolvidos: [a.id, b.id, c.id],
placar: { jogador: 9, drones: 5 },
drones: [],
segundos: 300,
};
describe("Relatorio", () => {
it("na vitória mostra o título, o placar e foca o título", () => {
render(<Relatorio resultado={base} aoJogarDeNovo={jest.fn()} />);
const titulo = screen.getByRole("heading", {
level: 1,
name: "Problemas resolvidos",
});
expect(titulo).toHaveFocus();
expect(screen.getByText("Você 9 · Drones 5")).toBeInTheDocument();
});
it("na derrota mostra o outro título", () => {
render(
<Relatorio
resultado={{ ...base, vitoria: false }}
aoJogarDeNovo={jest.fn()}
/>,
);
expect(
screen.getByRole("heading", {
level: 1,
name: "Os problemas venceram desta vez",
}),
).toBeInTheDocument();
});
it("cada cartão aponta para o destino do problema", () => {
render(<Relatorio resultado={base} aoJogarDeNovo={jest.fn()} />);
const links = screen.getAllByRole("link", {
name: /Ver como eu resolvo isso/,
});
expect(links).toHaveLength(3);
for (const [i, id] of base.resolvidos.entries()) {
expect(links[i]).toHaveAttribute("href", problemaPorId(id)?.destino);
}
});
it("o WhatsApp cita o problema escolhido, mesmo sem ter sido resolvido", () => {
render(
<Relatorio
resultado={{ ...base, vitoria: false, resolvidos: [a.id] }}
aoJogarDeNovo={jest.fn()}
/>,
);
const link = screen.getByRole("link", { name: "Chamar no WhatsApp" });
const href = link.getAttribute("href") ?? "";
expect(href).toMatch(/^https:\/\/wa\.me\/\d+\?text=/);
expect(href).toContain(encodeURIComponent(c.nome));
expect(
screen.getAllByRole("link", { name: /Ver como eu resolvo isso/ }),
).toHaveLength(1);
});
it("Jogar de novo chama a ação", async () => {
const aoJogarDeNovo = jest.fn();
render(<Relatorio resultado={base} aoJogarDeNovo={aoJogarDeNovo} />);
await userEvent.click(
screen.getByRole("button", { name: "Jogar de novo" }),
);
expect(aoJogarDeNovo).toHaveBeenCalledTimes(1);
});
});
+118
View File
@@ -0,0 +1,118 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import {
pageTop,
primaryButton,
secondaryButtonDark,
textLinkDark,
} from "@/app/components/button-styles";
import { problemaPorId } from "@/lib/jogo/problemas";
import { buildWhatsAppUrl } from "@/lib/whatsapp";
import type { Resultado } from "./contrato";
type Props = {
resultado: Resultado;
aoJogarDeNovo(): void;
};
export default function Relatorio({ resultado, aoJogarDeNovo }: Props) {
const titulo = useRef<HTMLHeadingElement>(null);
useEffect(() => {
titulo.current?.focus();
}, []);
const escolhido = problemaPorId(resultado.problemaId);
const resolvidos = resultado.resolvidos.flatMap((id) => {
const problema = problemaPorId(id);
return problema ? [problema] : [];
});
const mensagem = escolhido
? `Oi, Evandro. Joguei o Expediente dos Mortos e o meu problema é: ${escolhido.nome}. Podemos conversar?`
: "";
return (
<section
aria-labelledby="relatorio-titulo"
className={`bg-[var(--ink)] px-sp-6 pb-sp-16 pt-[calc(var(--site-header-height)+var(--sp-12))] font-display text-[#f4f2ed] lg:px-sp-12 ${pageTop}`}
>
<div className="ds-section-shell">
<h1
id="relatorio-titulo"
ref={titulo}
tabIndex={-1}
className="text-[clamp(32px,6vw,64px)] font-semibold leading-none tracking-[-0.04em] outline-none"
>
{resultado.vitoria
? "Problemas resolvidos"
: "Os problemas venceram desta vez"}
</h1>
<p className="mt-sp-6 text-[20px] font-medium leading-[1.3]">
Você {resultado.placar.jogador} · Drones {resultado.placar.drones}
</p>
{resolvidos.length > 0 ? (
<div className="mt-sp-10">
<h2 className="text-[24px] font-semibold leading-[1.15] tracking-[-0.02em]">
O que você resolveu
</h2>
<ul className="mt-sp-4 grid gap-sp-4 md:grid-cols-2">
{resolvidos.map((problema) => (
<li key={problema.id} className="border border-white/25 p-sp-4">
<h3 className="text-[18px] font-semibold leading-[1.25]">
{problema.nome}
</h3>
<p className="mt-sp-1 text-[16px] leading-[1.5] text-[rgba(244,242,237,.72)]">
{problema.solucao}
</p>
<Link
href={problema.destino}
className={`${textLinkDark} mt-sp-2`}
>
Ver como eu resolvo isso
<span className="sr-only">: {problema.nome}</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
{escolhido ? (
<div className="mt-sp-10 max-w-[44rem] border border-[var(--slash)] p-sp-6">
<h2 className="text-[24px] font-semibold leading-[1.15] tracking-[-0.02em]">
O chefão era o seu problema
</h2>
<p className="mt-sp-3 text-[18px] font-medium leading-[1.4]">
{escolhido.nome}
</p>
<p className="mt-sp-1 text-[16px] leading-[1.5] text-[rgba(244,242,237,.72)]">
{escolhido.solucao}
</p>
<div className="mt-sp-6">
<a
href={buildWhatsAppUrl(mensagem)}
target="_blank"
rel="noopener noreferrer"
className={`${primaryButton} !bg-[var(--whatsapp)] rounded-full`}
>
Chamar no WhatsApp
</a>
</div>
</div>
) : null}
<div className="mt-sp-10">
<button
type="button"
onClick={aoJogarDeNovo}
className={secondaryButtonDark}
>
Jogar de novo
</button>
</div>
</div>
</section>
);
}
+251
View File
@@ -0,0 +1,251 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ArmaId } from "@/lib/jogo/tipos";
import type { Controle } from "./contrato";
import { Toque } from "./Toque";
function controleFalso() {
return {
ler: jest.fn(),
definirMover: jest.fn(),
somarOlhar: jest.fn(),
definirAtirar: jest.fn(),
definirArma: jest.fn(),
soltar: jest.fn(),
descartar: jest.fn(),
} satisfies Controle;
}
function montar(
c: Controle,
extra: { arma?: ArmaId; municao?: number; mirando?: boolean } = {},
) {
const props = { arma: 0 as ArmaId, municao: 30, mirando: false, ...extra };
const vista = render(<Toque controle={c} {...props} />);
return {
...vista,
camada: vista.container.firstElementChild as HTMLElement,
de: (novas: Partial<typeof props>) =>
vista.rerender(<Toque controle={c} {...props} {...novas} />),
};
}
// a tela do jsdom tem 1024 px de largura: a metade esquerda vai até 511
const ESQUERDA = 100;
const DIREITA = 800;
describe("Toque: direcional", () => {
it("aparece onde o dedo encosta, move e some ao soltar", () => {
const c = controleFalso();
const { camada } = montar(c);
expect(screen.queryByTestId("direcional")).toBeNull();
fireEvent.pointerDown(camada, {
pointerId: 1,
clientX: ESQUERDA,
clientY: 300,
});
expect(screen.getByTestId("direcional")).toHaveStyle({
left: "100px",
top: "300px",
});
fireEvent.pointerMove(camada, {
pointerId: 1,
clientX: ESQUERDA + 28,
clientY: 300 - 28,
});
const [direita, frente] = c.definirMover.mock.calls.at(-1) ?? [];
expect(direita).toBeCloseTo(0.5);
expect(frente).toBeCloseTo(0.5);
fireEvent.pointerUp(camada, { pointerId: 1 });
expect(c.definirMover).toHaveBeenLastCalledWith(0, 0);
expect(screen.queryByTestId("direcional")).toBeNull();
});
it("um segundo dedo na metade esquerda não rouba o direcional", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, { pointerId: 1, clientX: 100, clientY: 300 });
fireEvent.pointerDown(camada, { pointerId: 2, clientX: 200, clientY: 200 });
fireEvent.pointerMove(camada, { pointerId: 2, clientX: 260, clientY: 200 });
expect(c.definirMover).not.toHaveBeenCalled();
});
it("cancelar o toque também solta o direcional", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, { pointerId: 1, clientX: 100, clientY: 300 });
fireEvent.pointerCancel(camada, { pointerId: 1 });
expect(c.definirMover).toHaveBeenLastCalledWith(0, 0);
});
});
describe("Toque: mira", () => {
it("arrastar na metade direita soma o giro", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, {
pointerId: 1,
clientX: DIREITA,
clientY: 200,
});
fireEvent.pointerMove(camada, {
pointerId: 1,
clientX: DIREITA + 40,
clientY: 210,
});
fireEvent.pointerMove(camada, {
pointerId: 1,
clientX: DIREITA + 60,
clientY: 210,
});
expect(c.somarOlhar).toHaveBeenCalledTimes(2);
const [a, b] = c.somarOlhar.mock.calls;
expect(a[0]).toBeCloseTo(-0.2);
expect(a[1]).toBeCloseTo(-0.05);
expect(b[0]).toBeCloseTo(-0.1);
expect(b[1]).toBeCloseTo(0);
expect(c.definirMover).not.toHaveBeenCalled();
});
it("depois de soltar, mover o ponteiro não gira mais", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, { pointerId: 1, clientX: 800, clientY: 200 });
fireEvent.pointerUp(camada, { pointerId: 1 });
fireEvent.pointerMove(camada, { pointerId: 1, clientX: 900, clientY: 200 });
expect(c.somarOlhar).not.toHaveBeenCalled();
});
it("dois dedos ao mesmo tempo: um anda e o outro mira", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, { pointerId: 1, clientX: 100, clientY: 300 });
fireEvent.pointerDown(camada, { pointerId: 2, clientX: 800, clientY: 200 });
fireEvent.pointerMove(camada, { pointerId: 2, clientX: 810, clientY: 200 });
fireEvent.pointerMove(camada, { pointerId: 1, clientX: 100, clientY: 250 });
expect(c.somarOlhar).toHaveBeenCalledTimes(1);
expect(c.definirMover).toHaveBeenCalledTimes(1);
expect(c.definirMover.mock.calls[0][1]).toBeCloseTo(0.89, 1);
});
it("um segundo dedo na metade direita não vira outra mira", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, { pointerId: 1, clientX: 800, clientY: 200 });
fireEvent.pointerDown(camada, { pointerId: 2, clientX: 900, clientY: 200 });
fireEvent.pointerMove(camada, { pointerId: 2, clientX: 950, clientY: 200 });
expect(c.somarOlhar).not.toHaveBeenCalled();
});
});
describe("Toque: botão Atirar", () => {
it("segurar atira e soltar para", () => {
const c = controleFalso();
montar(c);
c.definirAtirar.mockClear();
const botao = screen.getByRole("button", { name: "Atirar" });
fireEvent.pointerDown(botao, { pointerId: 1, clientX: 900, clientY: 350 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(true);
fireEvent.pointerUp(botao, { pointerId: 1 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(false);
});
it("arrastar o dedo sobre o botão também mira", () => {
const c = controleFalso();
montar(c);
const botao = screen.getByRole("button", { name: "Atirar" });
fireEvent.pointerDown(botao, { pointerId: 1, clientX: 900, clientY: 350 });
fireEvent.pointerMove(botao, { pointerId: 1, clientX: 920, clientY: 330 });
const [rumo, inclinacao] = c.somarOlhar.mock.calls[0];
expect(rumo).toBeCloseTo(-0.1);
expect(inclinacao).toBeCloseTo(0.1);
});
it("tocar no botão não liga o direcional nem a mira da camada", () => {
const c = controleFalso();
const { camada } = montar(c);
const botao = screen.getByRole("button", { name: "Atirar" });
fireEvent.pointerDown(botao, { pointerId: 1, clientX: 100, clientY: 350 });
expect(screen.queryByTestId("direcional")).toBeNull();
fireEvent.pointerUp(camada, { pointerId: 1 });
expect(c.definirMover).not.toHaveBeenCalled();
});
});
describe("Toque: armas", () => {
it("mostra as três armas, com as balas da automática e a escolhida marcada", () => {
montar(controleFalso(), { arma: 1, municao: 42 });
expect(screen.getByRole("button", { name: "Pistola" })).toHaveAttribute(
"aria-pressed",
"false",
);
expect(
screen.getByRole("button", { name: "Automática 42" }),
).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Faca" })).toHaveAttribute(
"aria-pressed",
"false",
);
});
it("tocar numa arma a escolhe, sem mirar nem mover", () => {
const c = controleFalso();
montar(c);
const faca = screen.getByRole("button", { name: "Faca" });
fireEvent.pointerDown(faca, { pointerId: 1, clientX: 700, clientY: 300 });
fireEvent.click(faca);
fireEvent.pointerUp(faca, { pointerId: 1 });
expect(c.definirArma).toHaveBeenCalledWith(2);
expect(c.somarOlhar).not.toHaveBeenCalled();
});
});
describe("Toque: tiro automático", () => {
it("liga enquanto mira e desliga ao deixar de mirar", () => {
const c = controleFalso();
const { de } = montar(c, { mirando: true });
expect(c.definirAtirar).toHaveBeenLastCalledWith(true);
de({ mirando: false });
expect(c.definirAtirar).toHaveBeenLastCalledWith(false);
});
it("não solta o tiro se o botão Atirar está apertado", () => {
const c = controleFalso();
const { de } = montar(c, { mirando: true });
const botao = screen.getByRole("button", { name: "Atirar" });
fireEvent.pointerDown(botao, { pointerId: 1, clientX: 900, clientY: 350 });
de({ mirando: false });
expect(c.definirAtirar).toHaveBeenLastCalledWith(true);
fireEvent.pointerUp(botao, { pointerId: 1 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(false);
});
it("soltar o botão mantém o tiro se ainda está mirando", () => {
const c = controleFalso();
montar(c, { mirando: true });
const botao = screen.getByRole("button", { name: "Atirar" });
fireEvent.pointerDown(botao, { pointerId: 1, clientX: 900, clientY: 350 });
fireEvent.pointerUp(botao, { pointerId: 1 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(true);
});
it("não atira sozinho com a faca", () => {
const c = controleFalso();
const { de } = montar(c, { arma: 2, mirando: true });
expect(c.definirAtirar).not.toHaveBeenCalledWith(true);
de({ arma: 0 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(true);
de({ arma: 2 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(false);
});
});
describe("Toque: camada", () => {
it("cobre a tela sem rolagem por gesto e bloqueia o menu de contexto", () => {
const { camada } = montar(controleFalso());
expect(camada.style.touchAction).toBe("none");
expect(fireEvent.contextMenu(camada)).toBe(false);
});
});
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { type PointerEvent, useEffect, useRef, useState } from "react";
import { alavanca } from "@/lib/jogo/alavanca";
import type { ArmaId } from "@/lib/jogo/tipos";
import type { Controle } from "./contrato";
import {
TOQUE_ATIRAR,
TOQUE_BOTAO,
TOQUE_DIREITA,
TOQUE_EMBAIXO,
TOQUE_ESPACO,
} from "./medidas";
const RAIO = 56;
const MIRA = 0.005;
const FACA: ArmaId = 2;
// right/bottom e tamanhos dos botões vêm de ./medidas, que o Hud também lê.
/** "tiro" é o dedo no botão Atirar: segura o tiro e também mira. */
type Dedo = { papel: "mover" | "mirar" | "tiro"; x: number; y: number };
type Direcional = { x: number; y: number; kx: number; ky: number };
const BOTAO =
"pointer-events-auto flex min-w-14 flex-col items-center justify-center border bg-[color:var(--ink)]/30 px-3 font-[family-name:var(--font-mono)] text-[13px] leading-tight tracking-[0.02em] text-[color:var(--paper)] select-none";
export function Toque({
controle,
arma,
municao,
mirando,
}: {
controle: Controle;
arma: ArmaId;
municao: number;
mirando: boolean;
}) {
const dedos = useRef(new Map<number, Dedo>());
const apertado = useRef(false);
const automatico = useRef(false);
const [direcional, setDirecional] = useState<Direcional | null>(null);
useEffect(() => {
automatico.current = mirando && arma !== FACA;
controle.definirAtirar(apertado.current || automatico.current);
}, [controle, mirando, arma]);
function aoApertar(e: PointerEvent<HTMLDivElement>) {
const papel = (e.target as HTMLElement).closest<HTMLElement>("[data-papel]")
?.dataset.papel;
if (papel === "arma") return;
const { clientX: x, clientY: y, pointerId } = e;
if (papel === "atirar") {
apertado.current = true;
controle.definirAtirar(true);
dedos.current.set(pointerId, { papel: "tiro", x, y });
return;
}
const lado = x < window.innerWidth / 2 ? "mover" : "mirar";
const ocupado = [...dedos.current.values()].some((d) => d.papel === lado);
if (ocupado) return;
dedos.current.set(pointerId, { papel: lado, x, y });
if (lado === "mover") setDirecional({ x, y, kx: 0, ky: 0 });
}
function aoMover(e: PointerEvent<HTMLDivElement>) {
const dedo = dedos.current.get(e.pointerId);
if (!dedo) return;
const dx = e.clientX - dedo.x;
const dy = e.clientY - dedo.y;
if (dedo.papel !== "mover") {
controle.somarOlhar(-dx * MIRA, -dy * MIRA);
dedo.x = e.clientX;
dedo.y = e.clientY;
return;
}
const [direita, frente] = alavanca(dx, dy, RAIO);
controle.definirMover(direita, frente);
setDirecional({
x: dedo.x,
y: dedo.y,
kx: direita * RAIO,
ky: -frente * RAIO,
});
}
function aoSoltar(e: PointerEvent<HTMLDivElement>) {
const dedo = dedos.current.get(e.pointerId);
if (!dedo) return;
dedos.current.delete(e.pointerId);
if (dedo.papel === "tiro") {
apertado.current = false;
controle.definirAtirar(automatico.current);
}
if (dedo.papel === "mover") {
controle.definirMover(0, 0);
setDirecional(null);
}
}
const armas: { id: ArmaId; nome: string }[] = [
{ id: 0, nome: "Pistola" },
{ id: 1, nome: "Automática" },
{ id: 2, nome: "Faca" },
];
return (
<div
role="application"
aria-label="Controles de toque"
className="absolute inset-0 select-none"
style={{ touchAction: "none" }}
onPointerDown={aoApertar}
onPointerMove={aoMover}
onPointerUp={aoSoltar}
onPointerCancel={aoSoltar}
onContextMenu={(e) => e.preventDefault()}
>
{direcional && (
<div
data-testid="direcional"
className="pointer-events-none absolute size-28 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[color:var(--slash)]/50"
style={{ left: direcional.x, top: direcional.y }}
>
<div
className="absolute top-1/2 left-1/2 size-12 rounded-full bg-[color:var(--slash)]/40"
style={{
transform: `translate(calc(-50% + ${direcional.kx}px), calc(-50% + ${direcional.ky}px))`,
}}
/>
</div>
)}
<div
className="pointer-events-none absolute flex flex-col items-end"
style={{
right: TOQUE_DIREITA,
bottom: TOQUE_EMBAIXO,
gap: TOQUE_ESPACO,
}}
>
<div className="flex" style={{ gap: TOQUE_ESPACO }}>
{armas.map(({ id, nome }) => (
<button
key={id}
type="button"
data-papel="arma"
aria-pressed={arma === id}
onClick={() => controle.definirArma(id)}
style={{ minHeight: TOQUE_BOTAO }}
className={`${BOTAO} ${arma === id ? "border-[color:var(--slash)] bg-[color:var(--slash)]/25" : "border-[color:var(--paper)]/40"}`}
>
{nome}
{id === 1 && <span>{municao}</span>}
</button>
))}
</div>
<button
type="button"
data-papel="atirar"
style={{ width: TOQUE_ATIRAR, height: TOQUE_ATIRAR }}
className={`${BOTAO} border-[color:var(--slash)]/70 active:bg-[color:var(--slash)]/30`}
>
Atirar
</button>
</div>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import {
Light,
Line,
Mesh,
type Object3D,
PerspectiveCamera,
Points,
Scene,
Sprite,
} from "three";
import { lerMapa } from "@/lib/jogo/mapa";
import { ESCRITORIO } from "@/lib/jogo/valores";
import { criarAmbiente } from "./ambiente";
beforeEach(() => {
jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
afterEach(() => {
jest.restoreAllMocks();
});
function montar() {
const cena = new Scene();
const camera = new PerspectiveCamera();
cena.add(camera);
const ambiente = criarAmbiente(cena, lerMapa(ESCRITORIO), {
camera,
movimentoReduzido: false,
});
return { cena, camera, ambiente };
}
test("o escritório cabe em 12 objetos desenháveis", () => {
const { cena } = montar();
let n = 0;
cena.traverse((o) => {
if (
o instanceof Mesh ||
o instanceof Sprite ||
o instanceof Line ||
o instanceof Points
)
n++;
});
expect(n).toBeGreaterThan(0);
expect(n).toBeLessThanOrEqual(12);
});
test("a lanterna presa à câmera é a única luz com sombra e desliga com o regulador", () => {
const { cena, ambiente } = montar();
const comSombra = () => {
const luzes: Object3D[] = [];
cena.traverse((o) => {
if (o instanceof Light && o.castShadow) luzes.push(o);
});
return luzes;
};
expect(comSombra()).toHaveLength(1);
expect(comSombra()[0].parent).toBeInstanceOf(PerspectiveCamera);
ambiente.definirSombras(false);
expect(comSombra()).toHaveLength(0);
});
test("descartar esvazia a cena e a câmera", () => {
const { cena, camera, ambiente } = montar();
ambiente.descartar();
expect(cena.children).toEqual([camera]);
expect(camera.children).toHaveLength(0);
expect(cena.fog).toBeNull();
});
+480
View File
@@ -0,0 +1,480 @@
/**
* O escritório à noite, montado a partir do `Mapa` da lógica (célula de 1 m). Dez chamadas de desenho: piso, forro,
* paredes, pilares, mesas, monitores, telas, painéis do teto, portas dos nascedouros e luzes de emergência.
* Texturas só de `<canvas>`.
*/
import {
BoxGeometry,
type Camera,
CanvasTexture,
Color,
Fog,
HemisphereLight,
InstancedMesh,
type Material,
Matrix4,
Mesh,
MeshBasicMaterial,
MeshStandardMaterial,
Object3D,
PlaneGeometry,
PointLight,
RepeatWrapping,
type Scene,
SpotLight,
SRGBColorSpace,
type Texture,
} from "three";
import type { Mapa } from "@/lib/jogo/tipos";
export type Ambiente = {
definirSombras(ligadas: boolean): void;
/** Pisca o painel com defeito; `segundos` é o relógio da partida. */
atualizar(segundos: number): void;
descartar(): void;
};
const PE_DIREITO = 3;
const ALTURA_DA_MESA = 0.74;
const NOITE = "#06070a";
const LIMA = new Color("#ccff00").multiplyScalar(0.85);
const AZUL_FRIO = new Color("#3d6bff").multiplyScalar(0.55);
const PAINEL_ACESO = new Color("#dfe9ff");
const PAINEL_APAGADO = new Color("#2a2e35");
/** Um painel a cada 4 m nos dois eixos. */
const ESPACO_DOS_PAINEIS = 4;
/** Onde ficam os painéis acesos, em fração do mapa; o último pisca. */
const PAINEIS_ACESOS: readonly (readonly [number, number])[] = [
[0.5, 0.5],
[0.2, 0.2],
[0.8, 0.2],
[0.75, 0.85],
];
/** Porta do nascedouro, com a frente para +z: largura, altura, profundidade e centro (x, y, z). */
const PECAS_DA_PORTA: readonly (readonly [
number,
number,
number,
number,
number,
number,
])[] = [
[0.9, 2.05, 0.03, 0, 1.025, 0.015],
[0.06, 2.14, 0.05, -0.48, 1.07, 0.025],
[0.06, 2.14, 0.05, 0.48, 1.07, 0.025],
[1.02, 0.06, 0.05, 0, 2.11, 0.025],
[0.7, 0.04, 0.04, 0, 1.02, 0.05],
];
/** Folha escura, batente e verga cor de vinho, barra antipânico. */
const CORES_DA_PORTA = [
new Color("#1c1a1c"),
new Color("#4a1414"),
new Color("#4a1414"),
new Color("#4a1414"),
new Color("#8a8f96"),
];
/** Textura feita num `<canvas>`; sem contexto 2D (jsdom) sai em branco. */
export function texturaDeCanvas(
largura: number,
altura: number,
desenhar: (ctx: CanvasRenderingContext2D) => void,
): CanvasTexture {
const canvas = document.createElement("canvas");
canvas.width = largura;
canvas.height = altura;
const ctx = canvas.getContext("2d");
if (ctx) desenhar(ctx);
const textura = new CanvasTexture(canvas);
textura.colorSpace = SRGBColorSpace;
return textura;
}
/** Pontos claros e escuros espalhados, para a superfície não ficar lisa. */
function salpicar(
ctx: CanvasRenderingContext2D,
lado: number,
quantos: number,
semente: number,
) {
let s = semente;
const sortear = () => {
s = (s * 16807) % 2147483647;
return s / 2147483647;
};
for (let i = 0; i < quantos; i++) {
ctx.fillStyle =
sortear() < 0.5 ? "rgba(0,0,0,0.25)" : "rgba(255,255,255,0.05)";
ctx.fillRect(sortear() * lado, sortear() * lado, 2, 2);
}
}
function repetir(textura: Texture, x: number, y: number): Texture {
textura.wrapS = RepeatWrapping;
textura.wrapT = RepeatWrapping;
textura.repeat.set(x, y);
return textura;
}
function texturaDoCarpete(): CanvasTexture {
return texturaDeCanvas(256, 256, (ctx) => {
ctx.fillStyle = "#24262b";
ctx.fillRect(0, 0, 256, 256);
salpicar(ctx, 256, 6000, 7);
ctx.strokeStyle = "rgba(0,0,0,0.35)";
ctx.lineWidth = 2;
ctx.strokeRect(0, 0, 256, 256);
});
}
/** Manchas largas e fracas, para o reboco ter variação sem parecer granulado de perto. */
function manchar(
ctx: CanvasRenderingContext2D,
largura: number,
altura: number,
quantas: number,
semente: number,
) {
let s = semente;
const sortear = () => {
s = (s * 16807) % 2147483647;
return s / 2147483647;
};
for (let i = 0; i < quantas; i++) {
const [cor, alfa] =
sortear() < 0.6 ? ["0,0,0", 0.05] : ["255,255,255", 0.025];
const x = sortear() * largura;
const y = sortear() * altura;
const raio = 20 + sortear() * 70;
// repete dos dois lados para a mancha continuar na célula vizinha sem costura
for (const deslocamento of [-largura, 0, largura]) {
const cx = x + deslocamento;
const borda = ctx.createRadialGradient(cx, y, 0, cx, y, raio);
borda.addColorStop(0, `rgba(${cor},${alfa})`);
borda.addColorStop(1, `rgba(${cor},0)`);
ctx.fillStyle = borda;
ctx.fillRect(cx - raio, y - raio, raio * 2, raio * 2);
}
}
}
/** 1 m × 3 m de parede em 256 × 768 px: o pixel fica quadrado na face. */
function texturaDaParede(): CanvasTexture {
return texturaDeCanvas(256, 768, (ctx) => {
ctx.fillStyle = "#3a3e46";
ctx.fillRect(0, 0, 256, 768);
manchar(ctx, 256, 768, 90, 11);
ctx.fillStyle = "rgba(0,0,0,0.3)";
ctx.fillRect(0, 0, 2, 768);
// rodapé de 10 cm
ctx.fillStyle = "#15171a";
ctx.fillRect(0, 742, 256, 26);
});
}
function texturaDoForro(): CanvasTexture {
return texturaDeCanvas(256, 256, (ctx) => {
ctx.fillStyle = "#30333a";
ctx.fillRect(0, 0, 256, 256);
salpicar(ctx, 256, 1500, 3);
ctx.strokeStyle = "#16181c";
ctx.lineWidth = 4;
ctx.strokeRect(0, 0, 128, 128);
ctx.strokeRect(128, 128, 128, 128);
ctx.strokeRect(128, 0, 128, 128);
ctx.strokeRect(0, 128, 128, 128);
});
}
function celula(mapa: Mapa, cx: number, cz: number): "#" | "M" | "." {
if (cx < 0 || cz < 0 || cx >= mapa.largura || cz >= mapa.altura) return ".";
const i = cz * mapa.largura + cx;
if (mapa.opaco[i]) return "#";
return mapa.solido[i] ? "M" : ".";
}
/** Parede de dentro com uma só vizinha de parede: é a ponta de um trecho, onde vai um pilar. */
function ehPilar(mapa: Mapa, cx: number, cz: number): boolean {
const borda =
cx === 0 || cz === 0 || cx === mapa.largura - 1 || cz === mapa.altura - 1;
if (borda) return false;
const vizinhas = [
celula(mapa, cx + 1, cz),
celula(mapa, cx - 1, cz),
celula(mapa, cx, cz + 1),
celula(mapa, cx, cz - 1),
].filter((c) => c === "#").length;
return vizinhas === 1;
}
function instancias(
geometria: BoxGeometry,
material: Material,
matrizes: readonly Matrix4[],
): InstancedMesh {
const malha = new InstancedMesh(geometria, material, matrizes.length);
matrizes.forEach((m, i) => {
malha.setMatrixAt(i, m);
});
return malha;
}
export function criarAmbiente(
cena: Scene,
mapa: Mapa,
opcoes: { camera: Camera; movimentoReduzido: boolean },
): Ambiente {
const objetos: Object3D[] = [];
const pos = new Object3D();
const matriz = (x: number, y: number, z: number, giro = 0) => {
pos.position.set(x, y, z);
pos.rotation.set(0, giro, 0);
pos.updateMatrix();
return pos.matrix.clone();
};
cena.background = new Color(NOITE);
// a neblina só engole o fundo: a 12 m ainda se vê a sala e quem vem nela
cena.fog = new Fog(NOITE, 9, 30);
const texturas = [
repetir(texturaDoCarpete(), mapa.largura / 2, mapa.altura / 2),
texturaDaParede(),
repetir(texturaDoForro(), mapa.largura / 1.2, mapa.altura / 1.2),
];
const [carpete, paredeTex, forroTex] = texturas;
const piso = new Mesh(
new PlaneGeometry(mapa.largura, mapa.altura),
new MeshStandardMaterial({ map: carpete, color: "#ffffff", roughness: 1 }),
);
piso.rotation.x = -Math.PI / 2;
piso.position.set(mapa.largura / 2, 0, mapa.altura / 2);
piso.receiveShadow = true;
const forro = new Mesh(
new PlaneGeometry(mapa.largura, mapa.altura),
new MeshStandardMaterial({ map: forroTex, roughness: 1 }),
);
forro.rotation.x = Math.PI / 2;
forro.position.set(mapa.largura / 2, PE_DIREITO, mapa.altura / 2);
const paredes: Matrix4[] = [];
const pilares: Matrix4[] = [];
const mesas: Matrix4[] = [];
const monitores: Matrix4[] = [];
const telas: Matrix4[] = [];
const coresDasTelas: Color[] = [];
const paineis: Matrix4[] = [];
const centrosDosPaineis: [number, number][] = [];
for (let cz = 0; cz < mapa.altura; cz++) {
for (let cx = 0; cx < mapa.largura; cx++) {
const c = celula(mapa, cx, cz);
const x = cx + 0.5;
const z = cz + 0.5;
if (c === "#") {
(ehPilar(mapa, cx, cz) ? pilares : paredes).push(
matriz(x, PE_DIREITO / 2, z),
);
continue;
}
if (cx % ESPACO_DOS_PAINEIS === 2 && cz % ESPACO_DOS_PAINEIS === 2) {
paineis.push(matriz(x, PE_DIREITO - 0.02, z));
centrosDosPaineis.push([x, z]);
}
if (c !== "M") continue;
mesas.push(matriz(x, ALTURA_DA_MESA / 2, z));
// Na fileira de cima do bloco de mesas a tela olha para −z; na de baixo, para +z.
const paraTras = celula(mapa, cx, cz + 1) === "M";
const sentido = paraTras ? -1 : 1;
const mz = z - sentido * 0.25;
const giro = paraTras ? Math.PI : 0;
monitores.push(matriz(x, ALTURA_DA_MESA + 0.25, mz, giro));
telas.push(matriz(x, ALTURA_DA_MESA + 0.26, mz + sentido * 0.03, giro));
coresDasTelas.push((cx * 7 + cz * 3) % 5 === 0 ? LIMA : AZUL_FRIO);
}
}
const paredeMat = new MeshStandardMaterial({
map: paredeTex,
roughness: 0.95,
});
const malhaDasParedes = instancias(
new BoxGeometry(1, PE_DIREITO, 1),
paredeMat,
paredes,
);
const malhaDosPilares = instancias(
new BoxGeometry(1.16, PE_DIREITO, 1.16),
new MeshStandardMaterial({ map: paredeTex, roughness: 0.85 }),
pilares,
);
const malhaDasMesas = instancias(
new BoxGeometry(0.98, ALTURA_DA_MESA, 0.98),
new MeshStandardMaterial({ color: "#3b342c", roughness: 0.7 }),
mesas,
);
const malhaDosMonitores = instancias(
new BoxGeometry(0.56, 0.36, 0.05),
new MeshStandardMaterial({ color: "#111214", roughness: 0.5 }),
monitores,
);
const malhaDasTelas = instancias(
new BoxGeometry(0.5, 0.3, 0.01),
new MeshBasicMaterial({ color: "#ffffff" }),
telas,
);
coresDasTelas.forEach((cor, i) => {
malhaDasTelas.setColorAt(i, cor);
});
for (const malha of [malhaDasParedes, malhaDosPilares, malhaDasMesas]) {
malha.castShadow = true;
malha.receiveShadow = true;
}
// Painéis do teto: a maioria apagada; os mais perto de `PAINEIS_ACESOS` acendem e ganham luz.
const malhaDosPaineis = instancias(
new BoxGeometry(1.2, 0.03, 0.6),
new MeshBasicMaterial({ color: "#ffffff" }),
paineis,
);
centrosDosPaineis.forEach((_, i) => {
malhaDosPaineis.setColorAt(i, PAINEL_APAGADO);
});
const luzesDosPaineis = PAINEIS_ACESOS.map(([fx, fz]) => {
const alvo = [fx * mapa.largura, fz * mapa.altura];
let melhor = 0;
centrosDosPaineis.forEach(([x, z], i) => {
const [mx, mz] = centrosDosPaineis[melhor];
if (
Math.hypot(x - alvo[0], z - alvo[1]) <
Math.hypot(mx - alvo[0], mz - alvo[1])
)
melhor = i;
});
malhaDosPaineis.setColorAt(melhor, PAINEL_ACESO);
const [x, z] = centrosDosPaineis[melhor];
const luz = new PointLight("#cfe0ff", 15, 15, 1.4);
luz.position.set(x, PE_DIREITO - 0.3, z);
return { luz, indice: melhor };
});
// Nascedouros: uma porta corta-fogo na parede do canto, com luminária vermelha em cima. É de lá que os zumbis vêm.
const portas: Matrix4[] = [];
const luminarias: Matrix4[] = [];
const luzesDeEmergencia = mapa.nascedouros.map(([x, z]) => {
const cx = Math.floor(x);
const cz = Math.floor(z);
// a face de parede vizinha e o giro que vira a frente da porta (+z) para dentro da sala
let [px, pz, giro] = [x, cz, 0];
if (celula(mapa, cx, cz + 1) === "#") [px, pz, giro] = [x, cz + 1, Math.PI];
else if (celula(mapa, cx - 1, cz) === "#")
[px, pz, giro] = [cx, z, Math.PI / 2];
else if (celula(mapa, cx + 1, cz) === "#")
[px, pz, giro] = [cx + 1, z, -Math.PI / 2];
const base = matriz(px, 0, pz, giro);
for (const [l, a, pr, ox, oy, oz] of PECAS_DA_PORTA)
portas.push(
base
.clone()
.multiply(new Matrix4().makeScale(l, a, pr).setPosition(ox, oy, oz)),
);
luminarias.push(
base
.clone()
.multiply(
new Matrix4().makeScale(0.56, 0.12, 0.1).setPosition(0, 2.3, 0.05),
),
);
const luz = new PointLight("#ff2020", 12, 10, 1.3);
luz.position.set(px + Math.sin(giro) * 0.6, 2.2, pz + Math.cos(giro) * 0.6);
return luz;
});
const malhaDasPortas = instancias(
new BoxGeometry(1, 1, 1),
new MeshStandardMaterial({ color: "#ffffff", roughness: 0.6 }),
portas,
);
portas.forEach((_, i) => {
malhaDasPortas.setColorAt(i, CORES_DA_PORTA[i % PECAS_DA_PORTA.length]);
});
const malhaDaEmergencia = instancias(
new BoxGeometry(1, 1, 1),
new MeshBasicMaterial({ color: "#ff1a1a" }),
luminarias,
);
// Luz de fundo fria, do forro para o chão: dá silhueta e forma à sala fora da lanterna, sem tirar a noite.
const ambiente = new HemisphereLight("#7d90c4", "#2a2622", 1.8);
// Lanterna presa à câmera, à esquerda da arma e abaixo dos olhos: a única luz com sombra. Daqui a arma fica na borda
// do facho e pega luz de lado, sem estourar de branco.
const lanterna = new SpotLight("#fff2dc", 40, 24, 0.5, 0.45, 1.5);
lanterna.position.set(-0.06, -0.14, 0.02);
lanterna.target.position.set(0, 0, -6);
lanterna.castShadow = true;
lanterna.shadow.mapSize.set(1024, 1024);
lanterna.shadow.bias = -0.0005;
lanterna.shadow.normalBias = 0.02;
lanterna.shadow.camera.near = 0.3;
opcoes.camera.add(lanterna, lanterna.target);
objetos.push(
piso,
forro,
malhaDasParedes,
malhaDosPilares,
malhaDasMesas,
malhaDosMonitores,
malhaDasTelas,
malhaDosPaineis,
malhaDasPortas,
malhaDaEmergencia,
ambiente,
...luzesDosPaineis.map((l) => l.luz),
...luzesDeEmergencia,
);
cena.add(...objetos);
const piscante = luzesDosPaineis[luzesDosPaineis.length - 1];
const intensidade = piscante.luz.intensity;
let aceso = true;
return {
definirSombras(ligadas) {
lanterna.castShadow = ligadas;
},
atualizar(segundos) {
if (opcoes.movimentoReduzido) return;
// apagões curtos e irregulares, como reator de lâmpada velha
const agora =
Math.sin(segundos * 23) * Math.sin(segundos * 7.3) +
Math.sin(segundos * 1.7) >
-0.6;
if (agora === aceso) return;
aceso = agora;
piscante.luz.intensity = aceso ? intensidade : 0;
malhaDosPaineis.setColorAt(
piscante.indice,
aceso ? PAINEL_ACESO : PAINEL_APAGADO,
);
if (malhaDosPaineis.instanceColor)
malhaDosPaineis.instanceColor.needsUpdate = true;
},
descartar() {
lanterna.shadow.dispose();
lanterna.removeFromParent();
lanterna.target.removeFromParent();
cena.remove(...objetos);
for (const objeto of objetos) {
if (!(objeto instanceof Mesh)) continue;
objeto.geometry.dispose();
(objeto.material as Material).dispose();
if (objeto instanceof InstancedMesh) objeto.dispose();
}
for (const textura of texturas) textura.dispose();
cena.fog = null;
cena.background = null;
},
};
}
+268
View File
@@ -0,0 +1,268 @@
import {
BoxGeometry,
Group,
Mesh,
MeshStandardMaterial,
type Object3D,
Scene,
Sprite,
Texture,
} from "three";
import { criarPartida } from "@/lib/jogo/partida";
import type { Partida, TipoZumbi, Zumbi } from "@/lib/jogo/tipos";
import { criarAtores } from "./atores";
import type { ModeloDoZumbi } from "./zumbis3d";
// O zumbis3d de verdade importa o GLTFLoader (ESM, que o Jest não transforma); aqui o clone é um `clone()` simples.
const mockTocados: string[] = [];
jest.mock("./zumbis3d", () => ({
criarZumbi: (modelo: ModeloDoZumbi) => {
const raiz = modelo.cena.clone();
return {
raiz,
tocar: (clipe: string, opcoes?: { umaVez?: boolean }) => {
mockTocados.push(opcoes?.umaVez ? `${clipe} uma vez` : clipe);
},
avancar: () => {},
descartar: () => raiz.removeFromParent(),
};
},
}));
/** Zumbi mínimo com as três peças do modelo. */
function modeloFalso(): ModeloDoZumbi {
const cena = new Group();
cena.name = "zumbi";
for (const nome of ["corpo", "roupa", "olhos"]) {
cena.add(
new Mesh(new BoxGeometry(), new MeshStandardMaterial({ name: nome })),
);
}
return { cena, clipes: [] };
}
function zumbi(id: number, x: number, z: number, tipo: TipoZumbi = "comum") {
const novo: Zumbi = {
id,
tipo,
problemaId: "site-lento",
x,
z,
rumo: 0,
vida: 100,
estado: "andando",
tempo: 0,
};
return novo;
}
function partidaCom(zumbis: Zumbi[], passo: number): Partida {
const p = criarPartida({
semente: 1,
dificuldade: "facil",
problemaId: "site-lento",
});
p.zumbis = zumbis;
p.passo = passo;
return p;
}
const ehZumbi = (o: Object3D) => o.name === "zumbi";
function zumbisNaCena(cena: Scene) {
const todos = cena.children.filter(ehZumbi);
return { total: todos.length, visiveis: todos.filter((o) => o.visible) };
}
beforeEach(() => {
jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
afterEach(() => {
jest.restoreAllMocks();
});
function montar() {
const cena = new Scene();
const atores = criarAtores(cena, {
zumbi: modeloFalso(),
bloco: new BoxGeometry(),
roupas: [new Texture(), new Texture()],
});
return { cena, atores };
}
test("cria, esconde e reaproveita zumbis sem a reserva passar do maior grupo", () => {
const { cena, atores } = montar();
const cinco = [1, 2, 3, 4, 5].map((id) => zumbi(id, 2 + id, 2));
atores.sincronizar(partidaCom(cinco, 1), 1, 0.016);
expect(zumbisNaCena(cena).total).toBe(5);
expect(zumbisNaCena(cena).visiveis).toHaveLength(5);
atores.sincronizar(partidaCom(cinco.slice(0, 2), 2), 1, 0.016);
expect(zumbisNaCena(cena).total).toBe(5);
expect(zumbisNaCena(cena).visiveis).toHaveLength(2);
const seis = [1, 2, 6, 7, 8, 9].map((id) => zumbi(id, 2 + id, 3));
atores.sincronizar(partidaCom(seis, 3), 1, 0.016);
const { total, visiveis } = zumbisNaCena(cena);
expect(total).toBe(6);
expect(visiveis).toHaveLength(6);
expect(visiveis.map((o) => o.position.x).sort((a, b) => a - b)).toEqual([
3, 4, 8, 9, 10, 11,
]);
});
test("toca o clipe do estado e veste roupa e escala do tipo", () => {
const { cena, atores } = montar();
mockTocados.length = 0;
const rapido = zumbi(1, 3, 3, "rapido");
const comum = zumbi(2, 4, 3);
const blindado = zumbi(3, 5, 3, "blindado");
atores.sincronizar(partidaCom([rapido, comum, blindado], 1), 1, 0);
expect(mockTocados).toEqual(["correr", "andar", "andar"]);
atores.sincronizar(
partidaCom(
[
{ ...rapido, estado: "atacando" },
{ ...comum, estado: "morrendo" },
blindado,
],
2,
),
1,
0,
);
expect(mockTocados.slice(3)).toEqual(["atacar", "morrer uma vez"]);
const [r, c, b] = cena.children.filter(ehZumbi);
const roupa = (o: Object3D) =>
(o.children[1] as Mesh).material as MeshStandardMaterial;
expect(r.scale.x).toBeCloseTo(0.95);
expect(b.scale.x).toBeCloseTo(1.08);
// ids 1, 2 e 3: roupa B, roupa C e a original escurecida e metálica
expect(new Set([roupa(r), roupa(c), roupa(b)]).size).toBe(3);
expect(roupa(b).metalness).toBeGreaterThan(0.5);
});
test("põe o zumbi no lugar e no rumo da lógica, interpolando entre passos", () => {
const { cena, atores } = montar();
const z = zumbi(1, 4, 4);
atores.sincronizar(partidaCom([z], 1), 1, 0);
const p2 = partidaCom([{ ...z, x: 5, rumo: 0.5 }], 2);
atores.guardarPasso(p2);
atores.sincronizar(p2, 0.5, 0);
const raiz = cena.children.find(ehZumbi);
expect(raiz?.position.x).toBeCloseTo(4.5);
expect(raiz?.position.z).toBeCloseTo(4);
expect(raiz?.rotation.y).toBeCloseTo(0.25);
});
test("dois passos no mesmo quadro: interpola entre o penúltimo e o último", () => {
const { cena, atores } = montar();
const z = zumbi(1, 4, 4);
atores.sincronizar(partidaCom([z], 1), 1, 0);
atores.guardarPasso(partidaCom([{ ...z, x: 5 }], 2));
const p3 = partidaCom([{ ...z, x: 6 }], 3);
atores.guardarPasso(p3);
atores.sincronizar(p3, 0.5, 0);
expect(cena.children.find(ehZumbi)?.position.x).toBeCloseTo(5.5);
});
test("com a partida parada, antes vira agora e nada treme", () => {
const { cena, atores } = montar();
const z = zumbi(1, 4, 4);
const drone = {
id: 9,
problemaId: "site-lento",
x: 13,
z: 9,
rumo: 0,
recarga: 0,
eliminacoes: 0,
};
const p1 = partidaCom([z], 1);
p1.drones = [drone];
atores.sincronizar(p1, 1, 0);
const p2 = partidaCom([{ ...z, x: 5 }], 2);
p2.drones = [{ ...drone, x: 14 }];
atores.guardarPasso(p2);
// terminada, a lógica não avança `passo`, mas a cena segue chamando a cada passo
atores.guardarPasso(p2);
for (const alfa of [0, 0.3, 0.9]) {
atores.sincronizar(p2, alfa, 0);
expect(cena.children.find(ehZumbi)?.position.x).toBeCloseTo(5);
const doDrone = cena.children.find(
(o) => o instanceof Sprite && o.name === "Site rápido",
);
expect(doDrone?.position.x).toBeCloseTo(14);
}
});
test("o rótulo do zumbi é o nome do problema e só aparece a até 10 m, menos no chefão", () => {
const { cena, atores } = montar();
// o jogador começa no centro do escritório (13,5; 8,5)
const perto = zumbi(1, 13.5, 5);
const longe = zumbi(2, 1.5, 1.5);
const chefao = zumbi(3, 26.5, 15.5, "chefao");
atores.sincronizar(partidaCom([perto, longe, chefao], 1), 1, 0);
const rotulos = cena.children.filter(
(o): o is Sprite => o instanceof Sprite && o.name !== "",
);
const em = (x: number) => rotulos.find((s) => s.position.x === x);
expect(em(13.5)?.name).toBe("Site lento");
expect(em(13.5)?.visible).toBe(true);
expect(em(1.5)?.visible).toBe(false);
expect(em(26.5)?.visible).toBe(true);
expect(em(26.5)?.material.depthTest).toBe(true);
});
test("só os 3 zumbis mais perto e o chefão levam rótulo, do mesmo tamanho na tela a qualquer distância", () => {
const { cena, atores } = montar();
const zumbis = [2, 3, 4, 5, 6].map((d, i) => zumbi(i + 1, 13.5, 8.5 + d));
zumbis.push(zumbi(9, 26.5, 15.5, "chefao"));
atores.sincronizar(partidaCom(zumbis, 1), 1, 0);
const rotulos = cena.children.filter(
(o): o is Sprite => o instanceof Sprite && o.visible,
);
expect(rotulos.map((s) => s.position.z).sort((a, b) => a - b)).toEqual([
10.5, 11.5, 12.5, 15.5,
]);
for (const s of rotulos) {
expect(s.material.sizeAttenuation).toBe(false);
expect(s.material.depthTest).toBe(true);
}
expect(rotulos[0].scale.y).toBeCloseTo(rotulos[1].scale.y, 10);
});
test("o drone leva o nome da solução, acima da cabeça e em rótulo menor que o do zumbi", () => {
const { cena, atores } = montar();
const p = partidaCom([zumbi(1, 13.5, 5)], 1);
p.drones = [
{
id: 9,
problemaId: "site-lento",
x: 13,
z: 9,
rumo: 0,
recarga: 0,
eliminacoes: 0,
},
];
atores.sincronizar(p, 1, 0);
const visivel = (nome: string) =>
cena.children.find(
(o): o is Sprite => o instanceof Sprite && o.visible && o.name === nome,
);
const doDrone = visivel("Site rápido");
expect(doDrone?.position.y).toBeGreaterThan(2.3);
expect(doDrone?.scale.y).toBeLessThan(visivel("Site lento")?.scale.y ?? 0);
expect(doDrone?.material.depthTest).toBe(false);
});
test("descartar tira tudo da cena", () => {
const { cena, atores } = montar();
atores.sincronizar(partidaCom([zumbi(1, 3, 3)], 1), 1, 0);
atores.descartar();
expect(cena.children).toHaveLength(0);
});
+577
View File
@@ -0,0 +1,577 @@
/**
* Zumbis, drones e caixas de munição desenhados a partir da `Partida`. Os zumbis vêm de uma reserva de clones que só
* cresce: quando um sai da partida, o clone se esconde e volta para outro. Cada zumbi leva o nome do problema que é,
* e cada drone o nome da solução.
*/
import {
BoxGeometry,
type BufferGeometry,
type CanvasTexture,
Color,
InstancedMesh,
type Material,
Matrix4,
type Mesh,
MeshStandardMaterial,
Object3D,
type Scene,
Sprite,
SpriteMaterial,
type Texture,
} from "three";
import { problemaPorId } from "@/lib/jogo/problemas";
import type { Drone, Partida, TipoZumbi, Zumbi } from "@/lib/jogo/tipos";
import { DIFICULDADES, HORDAS } from "@/lib/jogo/valores";
import { texturaDeCanvas } from "./ambiente";
import {
criarZumbi,
type ModeloDoZumbi,
type Zumbi as ZumbiNaTela,
} from "./zumbis3d";
/** O que os atores precisam já baixado. `roupas` são as variações B e C da roupa; a que faltar fica de fora. */
export type ModelosDosAtores = {
zumbi: ModeloDoZumbi;
bloco: BufferGeometry;
roupas: readonly Texture[];
};
export type Atores = {
/** Depois de cada passo da lógica: guarda posição e rumo de antes e de agora, para interpolar. */
guardarPasso(p: Partida): void;
/** `alfa` de 0 a 1 entre o passo anterior e o atual; `dt` em segundos do jogo, para as animações. */
sincronizar(p: Partida, alfa: number, dt: number): void;
descartar(): void;
};
const ESCALA: Record<TipoZumbi, number> = {
comum: 1,
rapido: 0.95,
blindado: 1.08,
chefao: 1.5,
};
/** Altura do modelo em escala 1, em metros. */
const ALTURA_DO_MODELO = 1.8;
const ALCANCE_DO_ROTULO = 10;
/** Só os zumbis mais perto (e o chefão) levam rótulo, para não empilhar nomes na tela. */
const ROTULOS_DE_ZUMBI = 3;
/** O rótulo do chefão não passa disto, para não entrar no forro (3 m). */
const TETO_DO_ROTULO = 2.8;
/** Metade do campo de visão vertical da câmera (75° em `cena.ts`), para medir o rótulo em pixels. */
const TANGENTE_DO_CAMPO = Math.tan((37.5 * Math.PI) / 180);
/** Rótulo do drone em relação ao do zumbi. */
const ESCALA_DO_ROTULO_DO_DRONE = 0.8;
/** Na tela, o drone fica acima da cabeça do jogador (olhos a 1,6 m); a lógica continua no chão. */
const ALTURA_DO_DRONE = 2.3;
const BLOCOS_POR_DRONE = 8;
const VERDE = "#c5f04a";
/** shortcut: no máximo 32 caixas desenhadas ao mesmo tempo; se o chão juntar mais, as velhas somem até alguém pegar. */
const MAX_CAIXAS = 32;
type Rotulo = {
material: SpriteMaterial;
textura: CanvasTexture;
aspecto: number;
texto: string;
};
type Trio = [number, number, number];
/** Posição e rumo no passo anterior e no atual. */
type Rastro = { antes: Trio; agora: Trio };
type Ator = Rastro & {
zumbi: ZumbiNaTela;
roupa: Mesh | null;
olhos: Mesh | null;
rotulo: Sprite;
aspecto: number;
id: number;
/** Último quadro em que o zumbi estava na partida. */
visto: number;
clipe: string;
};
function misturarAngulo(a: number, b: number, t: number): number {
const d = Math.atan2(Math.sin(b - a), Math.cos(b - a));
return a + d * t;
}
/** Põe antes e agora no mesmo lugar, sem alocar. */
function fixar(r: Rastro, x: number, z: number, rumo: number) {
r.antes[0] = r.agora[0] = x;
r.antes[1] = r.agora[1] = z;
r.antes[2] = r.agora[2] = rumo;
}
/** Um passo adiante sem alocar: o vetor de antes vira o de agora. Partida parada (passo igual): antes = agora. */
function seguir(
r: Rastro,
x: number,
z: number,
rumo: number,
parado: boolean,
) {
if (parado) {
fixar(r, r.agora[0], r.agora[1], r.agora[2]);
return;
}
const velho = r.antes;
r.antes = r.agora;
velho[0] = x;
velho[1] = z;
velho[2] = rumo;
r.agora = velho;
}
function malhaDoMaterial(raiz: Object3D, nome: string): Mesh | null {
let achada: Mesh | null = null;
raiz.traverse((objeto) => {
const malha = objeto as Mesh;
if (!achada && malha.isMesh && (malha.material as Material).name === nome)
achada = malha;
});
return achada;
}
function fonteMono(): string {
const raiz = getComputedStyle(document.documentElement);
return raiz.getPropertyValue("--font-mono").trim() || "monospace";
}
/** Etiqueta reta (cantos retos, como o site), texto em mono. */
function texturaDoRotulo(texto: string, fundo: string, tinta: string) {
const fonte = `500 46px ${fonteMono()}`;
const medida = document.createElement("canvas").getContext("2d");
if (medida) medida.font = fonte;
const largura = Math.ceil(
(medida?.measureText(texto).width ?? texto.length * 28) + 32,
);
const textura = texturaDeCanvas(largura, 64, (ctx) => {
ctx.fillStyle = fundo;
ctx.fillRect(0, 0, largura, 64);
ctx.font = fonte;
ctx.fillStyle = tinta;
ctx.textBaseline = "middle";
ctx.fillText(texto, 16, 34);
});
return { textura, aspecto: largura / 64 };
}
/** Altura do rótulo em unidades de `Sprite` sem atenuação: ~18 px no computador, ~14 px no celular. */
function alturaDoRotulo(): number {
const altura = window.innerHeight || 900;
const pixels = Math.min(18, Math.max(14, altura * 0.02));
return (pixels * 2 * TANGENTE_DO_CAMPO) / altura;
}
function clipeDo(z: Zumbi): string {
if (z.estado === "atacando") return "atacar";
if (z.estado === "morrendo") return "morrer";
return z.tipo === "rapido" ? "correr" : "andar";
}
export function criarAtores(cena: Scene, modelo: ModelosDosAtores): Atores {
const reserva: Ator[] = [];
const porId = new Map<number, Ator>();
const rotulos = new Map<string, Rotulo>();
const materiais = new Map<string, Material>();
let passoVisto = -1;
let relogio = 0;
let quadro = 0;
// os zumbis mais perto deste quadro, em reserva fixa
const maisPerto: (Ator | null)[] = Array(ROTULOS_DE_ZUMBI).fill(null);
const distanciasMaisPerto = new Float64Array(ROTULOS_DE_ZUMBI);
const roupaOriginal = malhaDoMaterial(modelo.zumbi.cena, "roupa")?.material as
| MeshStandardMaterial
| undefined;
const olhosOriginais = malhaDoMaterial(modelo.zumbi.cena, "olhos")
?.material as MeshStandardMaterial | undefined;
/** Material derivado, criado uma vez e guardado: variação de roupa, blindado escurecido, olhos do chefão. */
function derivado(
chave: string,
base: MeshStandardMaterial,
ajustar: (m: MeshStandardMaterial) => void,
): Material {
let material = materiais.get(chave);
if (!material) {
const novo = base.clone();
ajustar(novo);
materiais.set(chave, novo);
material = novo;
}
return material;
}
function roupaPara(z: Zumbi): Material | null {
if (!roupaOriginal) return null;
const variacao = z.id % 3;
const mapa = variacao > 0 ? modelo.roupas[variacao - 1] : undefined;
const base = mapa
? (derivado(`roupa-${variacao}`, roupaOriginal, (m) => {
m.map = mapa;
}) as MeshStandardMaterial)
: roupaOriginal;
if (z.tipo !== "blindado") return base;
return derivado(`blindado-${variacao}`, base, (m) => {
m.color.multiplyScalar(0.35);
m.metalness = 0.75;
m.roughness = 0.35;
});
}
function olhosPara(z: Zumbi): Material | null {
if (!olhosOriginais) return null;
if (z.tipo !== "chefao") return olhosOriginais;
return derivado("olhos-chefao", olhosOriginais, (m) => {
m.color.set("#ff2a1a");
m.emissive = new Color("#ff1a0a");
m.emissiveIntensity = 4;
});
}
function rotuloDo(
chave: string,
texto: string,
fundo: string,
tinta: string,
discreto = false,
): Rotulo {
let rotulo = rotulos.get(chave);
if (!rotulo) {
const { textura, aspecto } = texturaDoRotulo(texto, fundo, tinta);
// sem atenuação: o rótulo tem o mesmo tamanho na tela a 1 m ou a 10 m. O do zumbi testa profundidade para
// não atravessar parede; o do drone, discreto, não, senão o próprio drone come o texto.
const material = new SpriteMaterial({
map: textura,
fog: false,
sizeAttenuation: false,
opacity: discreto ? 0.85 : 1,
depthTest: !discreto,
});
rotulo = { material, textura, aspecto, texto };
rotulos.set(chave, rotulo);
}
return rotulo;
}
function aplicarRotulo(sprite: Sprite, rotulo: Rotulo) {
sprite.material = rotulo.material;
sprite.name = rotulo.texto;
}
function novoAtor(): Ator {
const zumbi = criarZumbi(modelo.zumbi);
zumbi.raiz.traverse((objeto) => {
objeto.castShadow = true;
});
// os olhos são pequenos demais para sombra; poupa uma chamada por zumbi
const olhos = malhaDoMaterial(zumbi.raiz, "olhos");
if (olhos) olhos.castShadow = false;
const rotulo = new Sprite();
rotulo.renderOrder = 10;
// ancorado embaixo: o rótulo sobe a partir do ponto acima da cabeça
rotulo.center.set(0.5, 0);
cena.add(zumbi.raiz, rotulo);
const ator: Ator = {
zumbi,
roupa: malhaDoMaterial(zumbi.raiz, "roupa"),
olhos,
rotulo,
aspecto: 1,
id: -1,
visto: 0,
clipe: "",
antes: [0, 0, 0],
agora: [0, 0, 0],
};
reserva.push(ator);
return ator;
}
/** Prepara um clone livre (ou novo) para o zumbi `z`. */
function vestir(z: Zumbi): Ator {
const ator = reserva.find((a) => a.id < 0) ?? novoAtor();
ator.id = z.id;
fixar(ator, z.x, z.z, z.rumo);
ator.zumbi.raiz.scale.setScalar(ESCALA[z.tipo]);
ator.zumbi.raiz.visible = true;
const roupa = roupaPara(z);
if (ator.roupa && roupa) ator.roupa.material = roupa;
const olhos = olhosPara(z);
if (ator.olhos && olhos) ator.olhos.material = olhos;
const problema = problemaPorId(z.problemaId);
const chefao = z.tipo === "chefao";
const rotulo = rotuloDo(
`${chefao ? "chefao" : "zumbi"}:${z.problemaId}`,
problema?.nome ?? z.problemaId,
"rgba(10,10,10,0.78)",
chefao ? "#ff5a4a" : "#f4f2ed",
);
aplicarRotulo(ator.rotulo, rotulo);
ator.aspecto = rotulo.aspecto;
// degraus por id, na tela: rótulos de zumbis juntos não se sobrepõem por inteiro
ator.rotulo.center.y = -(z.id % 3) * 1.15;
ator.clipe = clipeDo(z);
ator.zumbi.tocar(ator.clipe, { umaVez: ator.clipe === "morrer" });
// fases diferentes, para a horda não andar em marcha
ator.zumbi.avancar((z.id * 0.37) % 1.2);
porId.set(z.id, ator);
return ator;
}
function soltar(ator: Ator) {
porId.delete(ator.id);
ator.id = -1;
ator.zumbi.raiz.visible = false;
ator.rotulo.visible = false;
}
// Drones: os 8 blocos de cada um numa malha só.
const maxDrones = HORDAS.tamanhos.length;
const verde = new MeshStandardMaterial({
color: VERDE,
emissive: VERDE,
emissiveIntensity: 0.45,
roughness: 0.5,
});
const blocos = new InstancedMesh(
modelo.bloco,
verde,
maxDrones * BLOCOS_POR_DRONE,
);
blocos.count = 0;
blocos.frustumCulled = false;
const rotulosDosDrones = Array.from({ length: maxDrones }, () => {
const sprite = new Sprite();
sprite.renderOrder = 10;
sprite.center.set(0.5, 0);
sprite.visible = false;
return sprite;
});
const problemasDosRotulos: string[] = Array(maxDrones).fill("");
const aspectosDosRotulos = new Float64Array(maxDrones);
const posicoesDosDrones = new Map<number, Rastro>();
const materialDaCaixa = new MeshStandardMaterial({
color: "#d9a400",
emissive: "#d9a400",
emissiveIntensity: 0.6,
roughness: 0.6,
});
const caixas = new InstancedMesh(
new BoxGeometry(0.32, 0.2, 0.24),
materialDaCaixa,
MAX_CAIXAS,
);
caixas.count = 0;
caixas.frustumCulled = false;
cena.add(blocos, caixas, ...rotulosDosDrones);
const peca = new Object3D();
const corpo = new Object3D();
const m = new Matrix4();
function guardarPasso(p: Partida) {
// terminada, a partida não anda mais: sem isto antes ≠ agora e a interpolação treme
const parado = p.passo === passoVisto;
passoVisto = p.passo;
for (const z of p.zumbis) {
const ator = porId.get(z.id);
if (ator) seguir(ator, z.x, z.z, z.rumo, parado);
}
for (const d of p.drones) {
const pos = posicoesDosDrones.get(d.id);
if (pos) seguir(pos, d.x, d.z, d.rumo, parado);
}
}
/** O rastro do drone; o que acabou de nascer começa parado onde está. */
function rastroDo(d: Drone): Rastro {
let pos = posicoesDosDrones.get(d.id);
if (!pos) {
pos = { antes: [d.x, d.z, d.rumo], agora: [d.x, d.z, d.rumo] };
posicoesDosDrones.set(d.id, pos);
}
return pos;
}
/** Guarda `ator` entre os mais perto deste quadro, em ordem de distância. */
function guardarMaisPerto(ator: Ator, distancia: number) {
let i = ROTULOS_DE_ZUMBI - 1;
if (distancia >= distanciasMaisPerto[i]) return;
while (i > 0 && distanciasMaisPerto[i - 1] > distancia) {
distanciasMaisPerto[i] = distanciasMaisPerto[i - 1];
maisPerto[i] = maisPerto[i - 1];
i--;
}
distanciasMaisPerto[i] = distancia;
maisPerto[i] = ator;
}
function sincronizarZumbis(
p: Partida,
alfa: number,
dt: number,
alturaDoTexto: number,
) {
quadro++;
const ritmo = DIFICULDADES[p.dificuldade].velocidade;
const { x: jx, z: jz } = p.jogador;
distanciasMaisPerto.fill(Number.POSITIVE_INFINITY);
maisPerto.fill(null);
for (const z of p.zumbis) {
const ator = porId.get(z.id) ?? vestir(z);
ator.visto = quadro;
const clipe = clipeDo(z);
if (clipe !== ator.clipe) {
ator.clipe = clipe;
ator.zumbi.tocar(clipe, { umaVez: clipe === "morrer" });
}
const [x0, z0, r0] = ator.antes;
const [x1, z1, r1] = ator.agora;
const x = x0 + (x1 - x0) * alfa;
const zz = z0 + (z1 - z0) * alfa;
const raiz = ator.zumbi.raiz;
raiz.position.set(x, 0, zz);
raiz.rotation.y = misturarAngulo(r0, r1, alfa);
// andar e correr no ritmo da dificuldade, para o pé não deslizar no chão
ator.zumbi.avancar(z.estado === "andando" ? dt * ritmo : dt);
const altura = ALTURA_DO_MODELO * ESCALA[z.tipo] + 0.25;
const rotulo = ator.rotulo;
rotulo.position.set(x, Math.min(altura, TETO_DO_ROTULO), zz);
rotulo.scale.set(alturaDoTexto * ator.aspecto, alturaDoTexto, 1);
rotulo.visible = z.tipo === "chefao" && z.estado !== "morrendo";
if (z.tipo === "chefao" || z.estado === "morrendo") continue;
const distancia = Math.hypot(x - jx, zz - jz);
if (distancia <= ALCANCE_DO_ROTULO) guardarMaisPerto(ator, distancia);
}
for (let i = 0; i < ROTULOS_DE_ZUMBI; i++) {
const ator = maisPerto[i];
if (ator) ator.rotulo.visible = true;
}
for (const ator of reserva)
if (ator.id >= 0 && ator.visto !== quadro) soltar(ator);
}
function sincronizarDrones(p: Partida, alfa: number, alturaDoTexto: number) {
const n = Math.min(p.drones.length, maxDrones);
blocos.count = n * BLOCOS_POR_DRONE;
for (let i = 0; i < n; i++) {
const d = p.drones[i];
const pos = rastroDo(d);
const [x0, z0, r0] = pos.antes;
const [x1, z1, r1] = pos.agora;
const x = x0 + (x1 - x0) * alfa;
const z = z0 + (z1 - z0) * alfa;
const y = ALTURA_DO_DRONE + Math.sin(relogio * 2 + d.id) * 0.06;
corpo.position.set(x, y, z);
corpo.rotation.set(
Math.sin(relogio * 1.3 + d.id) * 0.08,
misturarAngulo(r0, r1, alfa),
0,
);
corpo.updateMatrix();
for (let b = 0; b < BLOCOS_POR_DRONE; b++) {
peca.position.set(
(b & 1 ? 1 : -1) * 0.07,
(b & 2 ? 1 : -1) * 0.07,
(b & 4 ? 1 : -1) * 0.07,
);
peca.scale.setScalar(0.12);
peca.updateMatrix();
m.multiplyMatrices(corpo.matrix, peca.matrix);
blocos.setMatrixAt(i * BLOCOS_POR_DRONE + b, m);
}
const sprite = rotulosDosDrones[i];
// troca a etiqueta só quando muda o drone da vaga: nada de texto novo por quadro
if (problemasDosRotulos[i] !== d.problemaId) {
problemasDosRotulos[i] = d.problemaId;
const problema = problemaPorId(d.problemaId);
const rotulo = rotuloDo(
`drone:${d.problemaId}`,
problema?.solucao ?? d.problemaId,
"rgba(10,10,10,0.55)",
"#ccff00",
true,
);
aplicarRotulo(sprite, rotulo);
aspectosDosRotulos[i] = rotulo.aspecto;
}
const alturaDoRotuloDoDrone = alturaDoTexto * ESCALA_DO_ROTULO_DO_DRONE;
sprite.scale.set(
alturaDoRotuloDoDrone * aspectosDosRotulos[i],
alturaDoRotuloDoDrone,
1,
);
// acima do topo do drone visto de baixo, para o texto não cair sobre os blocos
sprite.position.set(x, y + 0.34, z);
sprite.visible = true;
}
for (let i = n; i < maxDrones; i++) rotulosDosDrones[i].visible = false;
blocos.instanceMatrix.needsUpdate = true;
}
function sincronizarCaixas(p: Partida) {
const n = Math.min(p.caixas.length, MAX_CAIXAS);
caixas.count = n;
for (let i = 0; i < n; i++) {
const c = p.caixas[i];
peca.position.set(c.x, 0.22 + Math.sin(relogio * 3 + c.id) * 0.05, c.z);
peca.rotation.set(0, relogio * 1.5 + c.id, 0);
peca.scale.setScalar(1);
peca.updateMatrix();
caixas.setMatrixAt(i, peca.matrix);
}
peca.rotation.set(0, 0, 0);
caixas.instanceMatrix.needsUpdate = true;
}
return {
guardarPasso,
sincronizar(p, alfa, dt) {
relogio += dt;
const alturaDoTexto = alturaDoRotulo();
sincronizarZumbis(p, alfa, dt, alturaDoTexto);
sincronizarDrones(p, alfa, alturaDoTexto);
sincronizarCaixas(p);
},
descartar() {
for (const ator of reserva) {
ator.zumbi.descartar();
ator.rotulo.removeFromParent();
}
reserva.length = 0;
porId.clear();
cena.remove(blocos, caixas, ...rotulosDosDrones);
blocos.dispose();
caixas.dispose();
verde.dispose();
materialDaCaixa.dispose();
caixas.geometry.dispose();
modelo.bloco.dispose();
// a geometria dos sprites é uma só, do módulo do three: soltar libera o renderer desta partida
rotulosDosDrones[0].geometry.dispose();
for (const { material, textura } of rotulos.values()) {
material.dispose();
textura.dispose();
}
rotulos.clear();
for (const material of materiais.values()) material.dispose();
materiais.clear();
for (const textura of modelo.roupas) textura.dispose();
// o modelo fica em cache para a próxima partida; dispose só solta a GPU (e os ouvintes) deste renderer
modelo.zumbi.cena.traverse((objeto) => {
const malha = objeto as Mesh;
if (!malha.isMesh) return;
malha.geometry.dispose();
const material = malha.material as MeshStandardMaterial;
for (const valor of Object.values(material))
if ((valor as Texture | null)?.isTexture)
(valor as Texture).dispose();
material.dispose();
});
},
};
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import Bancada from "../Bancada";
export const metadata: Metadata = {
title: "Bancada do jogo",
robots: { index: false, follow: false },
};
/** Inteiro da busca, dentro de `[min, max]`; ausente ou inválido vira `padrao`. */
function inteiro(
valor: string | string[] | undefined,
padrao: number,
min: number,
max: number,
) {
const numero = Number.parseInt(String(valor), 10);
return Number.isFinite(numero)
? Math.min(max, Math.max(min, numero))
: padrao;
}
export default async function BancadaPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const busca = await searchParams;
return (
<Bancada
zumbis={inteiro(busca.zumbis, 16, 1, 64)}
seg={inteiro(busca.seg, 20, 2, 120)}
/>
);
}
+435
View File
@@ -0,0 +1,435 @@
/**
* A partida na tela: renderer, câmera, laço de passo fixo, regulador de qualidade e a ligação entre a lógica
* (`lib/jogo`) e o que se desenha (`ambiente`, `atores`, `efeitos`).
*/
import {
type BufferGeometry,
type Mesh,
PerspectiveCamera,
Scene,
SRGBColorSpace,
type Texture,
TextureLoader,
WebGLRenderer,
} from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { alvoNaMira } from "@/lib/jogo/armas";
import { avancar, criarPartida } from "@/lib/jogo/partida";
import { pilotar } from "@/lib/jogo/piloto";
import { amostrar, criarRegulador } from "@/lib/jogo/regulador";
import type { Partida } from "@/lib/jogo/tipos";
import { JOGADOR, NIVEIS, PASSO } from "@/lib/jogo/valores";
import { criarAmbiente } from "./ambiente";
import { criarAtores } from "./atores";
import type { Controle, Escolha, Resultado, Retrato } from "./contrato";
import { criarEfeitos } from "./efeitos";
import type { Som } from "./som";
import { carregarZumbi } from "./zumbis3d";
export type OpcoesDaCena = {
canvas: HTMLCanvasElement;
escolha: Escolha;
semente: number;
controle: Controle;
som: Som;
movimentoReduzido: boolean;
/** true: quem joga é `pilotar` de lib/jogo/piloto (testes e medição). */
piloto?: boolean;
/** Passos da lógica por passo de tempo real; padrão 1 (testes usam 8). */
velocidade?: number;
/** true se quem pediu a cena desistiu (saiu antes de carregar): conferido depois de cada `await`. */
cancelada(): boolean;
/** No máximo 10 vezes por segundo, e sempre que fase ou pausa mudar. */
aoRetrato(retrato: Retrato): void;
/** Só quando muda: a mira está ou não num zumbi. */
aoMirar(mirando: boolean): void;
/** Uma vez, 2 s depois de a fase virar vitoria ou derrota. */
aoTerminar(resultado: Resultado): void;
};
export type Cena = { pausar(): void; continuar(): void; descartar(): void };
/** O que os testes de ponta a ponta leem da partida. */
export type EstadoDoJogo = {
fase: Partida["fase"];
horda: number;
passo: number;
vida: number;
placar: { jogador: number; drones: number };
x: number;
z: number;
rumo: number;
nivel: number;
/** p95 dos últimos quadros, em ms. */
p95: number;
geometrias: number;
texturas: number;
chamadas: number;
/** Só depois de `descartar`: o que o renderer ainda contava como vivo. O esperado é 0 e 0. */
sobras?: { geometrias: number; texturas: number };
};
declare global {
interface Window {
__jogo?: EstadoDoJogo;
}
}
const MAX_PASSOS_POR_QUADRO = 5;
const INTERVALO_DO_RETRATO = 100;
const ESPERA_DO_FIM = 2;
/** Quadros guardados para o p95 de `window.__jogo`. */
const QUADROS_DO_P95 = 120;
async function carregarRoupa(nome: string): Promise<Texture | null> {
try {
const textura = await new TextureLoader().loadAsync(
`/jogo/roupa-${nome}.jpg`,
);
// como as texturas do glTF
textura.flipY = false;
textura.colorSpace = SRGBColorSpace;
return textura;
} catch {
return null;
}
}
async function carregarBloco(): Promise<BufferGeometry> {
const gltf = await new GLTFLoader().loadAsync("/3d/bloco.glb");
return (gltf.scene.children[0] as Mesh).geometry;
}
function retratar(p: Partida, pausado: boolean): Retrato {
return {
fase: p.fase,
horda: p.horda,
problemaAtual: p.hordas[p.horda],
restantes:
p.fila.length + p.zumbis.filter((z) => z.estado !== "morrendo").length,
vida: Math.max(0, p.jogador.vida),
arma: p.jogador.arma,
municao: p.jogador.municao,
placar: { ...p.placar },
feed: [...p.feed],
drones: p.drones.map((d) => ({
problemaId: d.problemaId,
eliminacoes: d.eliminacoes,
})),
pausado,
};
}
function mudou(a: Retrato, b: Retrato): boolean {
return (
a.fase !== b.fase ||
a.horda !== b.horda ||
a.restantes !== b.restantes ||
a.vida !== b.vida ||
a.arma !== b.arma ||
a.municao !== b.municao ||
a.placar.jogador !== b.placar.jogador ||
a.placar.drones !== b.placar.drones ||
a.feed.length !== b.feed.length ||
a.feed[a.feed.length - 1]?.passo !== b.feed[b.feed.length - 1]?.passo ||
a.drones.length !== b.drones.length ||
a.drones.some((d, i) => d.eliminacoes !== b.drones[i].eliminacoes) ||
a.pausado !== b.pausado
);
}
function p95(quadros: readonly number[]): number {
if (quadros.length === 0) return 0;
const ordenados = [...quadros].sort((a, b) => a - b);
return ordenados[Math.ceil(0.95 * ordenados.length) - 1];
}
/**
* A tabela de PBR (`DFG_LUT`) que o three cria uma vez no módulo e põe nos uniforms dos materiais padrão: o renderer
* a envia para a GPU mas não a solta no `dispose`. Vem de um material já desenhado; null se nenhum usou.
*/
function tabelaDePbr(renderer: WebGLRenderer, cena: Scene): Texture | null {
let achada: Texture | null = null;
cena.traverse((objeto) => {
const material = (objeto as Mesh).material;
if (achada || !material || Array.isArray(material)) return;
const propriedades = renderer.properties.get(material) as {
uniforms?: { dfgLUT?: { value: Texture | null } };
};
achada = propriedades.uniforms?.dfgLUT?.value ?? null;
});
return achada;
}
export async function criarCena(o: OpcoesDaCena): Promise<Cena> {
const velocidade = o.velocidade ?? 1;
const [zumbi, bloco, roupaB, roupaC] = await Promise.all([
carregarZumbi(),
carregarBloco(),
carregarRoupa("b"),
carregarRoupa("c"),
]);
const roupas = [roupaB, roupaC].filter((t): t is Texture => t !== null);
function soltarCarga() {
bloco.dispose();
for (const textura of roupas) textura.dispose();
}
// o jogador saiu durante a carga: nada de renderer nem cena
if (o.cancelada()) {
soltarCarga();
throw new Error("Carga cancelada");
}
let renderer: WebGLRenderer;
try {
renderer = new WebGLRenderer({
canvas: o.canvas,
antialias: true,
powerPreference: "high-performance",
});
} catch (erro) {
soltarCarga();
throw erro;
}
renderer.shadowMap.enabled = true;
const p = criarPartida({ semente: o.semente, ...o.escolha });
const cena = new Scene();
const camera = new PerspectiveCamera(75, 1, 0.05, 60);
camera.rotation.order = "YXZ";
cena.add(camera);
const ambiente = criarAmbiente(cena, p.mapa, {
camera,
movimentoReduzido: o.movimentoReduzido,
});
const atores = criarAtores(cena, { zumbi, bloco, roupas });
const efeitos = criarEfeitos(cena, camera, {
movimentoReduzido: o.movimentoReduzido,
som: o.som,
});
const regulador = criarRegulador(window.devicePixelRatio || 1);
let nivel = -1;
function aplicarNivel(novo: number) {
if (novo === nivel) return;
nivel = novo;
const degrau = NIVEIS[novo];
renderer.setPixelRatio(
Math.min(degrau.pixelRatio, window.devicePixelRatio || 1),
);
ambiente.definirSombras(degrau.sombras);
p.tetoAVista = degrau.teto;
}
function redimensionar() {
const largura = o.canvas.clientWidth || window.innerWidth;
const altura = o.canvas.clientHeight || window.innerHeight;
renderer.setSize(largura, altura, false);
camera.aspect = largura / altura;
camera.updateProjectionMatrix();
}
aplicarNivel(regulador.nivel);
redimensionar();
// jogador no passo anterior, para interpolar a câmera
const antes = { x: p.jogador.x, z: p.jogador.z, rumo: p.jogador.rumo };
let acumulado = 0;
let ultimo = -1;
let relogio = 0;
let pausado = false;
let raf = 0;
let fimEm: number | null = null;
let terminou = false;
let mirando = false;
const quadros: number[] = [];
let retrato = retratar(p, false);
let ultimoRetrato = -Infinity;
function publicar(agora: number, forcar: boolean) {
ultimoRetrato = agora;
const novo = retratar(p, pausado);
if (forcar || mudou(novo, retrato)) {
retrato = novo;
o.aoRetrato(novo);
}
const { memory, render } = renderer.info;
window.__jogo = {
fase: p.fase,
horda: p.horda,
passo: p.passo,
vida: p.jogador.vida,
placar: { ...p.placar },
x: p.jogador.x,
z: p.jogador.z,
rumo: p.jogador.rumo,
nivel,
p95: Math.round(p95(quadros) * 10) / 10,
geometrias: memory.geometries,
texturas: memory.textures,
chamadas: render.calls,
};
}
function passo() {
antes.x = p.jogador.x;
antes.z = p.jogador.z;
antes.rumo = p.jogador.rumo;
avancar(p, o.piloto ? pilotar(p) : o.controle.ler());
atores.guardarPasso(p);
for (const evento of p.eventos) efeitos.aoEvento(evento, p);
}
function terminar() {
terminou = true;
o.aoTerminar({
vitoria: p.fase === "vitoria",
problemaId: p.problemaId,
dificuldade: p.dificuldade,
resolvidos: [...p.resolvidos],
placar: { ...p.placar },
drones: p.drones.map((d) => ({
problemaId: d.problemaId,
eliminacoes: d.eliminacoes,
})),
segundos: Math.round(p.passo * PASSO),
});
}
function desenhar(alfa: number, dt: number) {
const t = Math.min(1, alfa);
camera.position.set(
antes.x + (p.jogador.x - antes.x) * t,
JOGADOR.olhos,
antes.z + (p.jogador.z - antes.z) * t,
);
camera.rotation.set(
p.jogador.inclinacao,
antes.rumo + (p.jogador.rumo - antes.rumo) * t + Math.PI,
0,
);
atores.sincronizar(p, t, dt * velocidade);
ambiente.atualizar(relogio);
efeitos.avancar(dt, p);
renderer.render(cena, camera);
}
/** Roda os passos de lógica que cabem no tempo acumulado; devolve quantos rodaram. */
function rodarPassos(): number {
const maximo = MAX_PASSOS_POR_QUADRO * velocidade;
let passos = 0;
while (acumulado >= PASSO && passos < maximo) {
passo();
acumulado -= PASSO;
passos++;
}
// atrasou demais (aba lenta): descarta o resto em vez de correr atrás
if (passos === maximo) acumulado = Math.min(acumulado, PASSO);
return passos;
}
function conferirMira() {
const novaMira = alvoNaMira(p) !== null;
if (novaMira === mirando) return;
mirando = novaMira;
o.aoMirar(mirando);
}
function quadro(agora: number) {
const inicio = performance.now();
raf = requestAnimationFrame(quadro);
const ms = ultimo < 0 ? 0 : agora - ultimo;
ultimo = agora;
const dt = Math.min(ms / 1000, 0.25);
relogio += dt;
acumulado += dt * velocidade;
const fase = p.fase;
// a mira só muda quando a lógica anda
if (rodarPassos() > 0) conferirMira();
if (fimEm === null && (p.fase === "vitoria" || p.fase === "derrota"))
fimEm = relogio;
if (!terminou && fimEm !== null && relogio - fimEm >= ESPERA_DO_FIM)
terminar();
desenhar(acumulado / PASSO, dt);
if (ms > 0) {
aplicarNivel(amostrar(regulador, ms, performance.now() - inicio));
quadros.push(ms);
if (quadros.length > QUADROS_DO_P95) quadros.shift();
}
if (p.fase !== fase || agora - ultimoRetrato >= INTERVALO_DO_RETRATO)
publicar(agora, p.fase !== fase);
}
function pausar() {
if (pausado) return;
pausado = true;
cancelAnimationFrame(raf);
o.controle.soltar();
publicar(performance.now(), true);
}
function continuar() {
if (!pausado || semContexto) return;
pausado = false;
ultimo = -1;
raf = requestAnimationFrame(quadro);
publicar(performance.now(), true);
}
function aoMudarVisibilidade() {
if (document.hidden) pausar();
}
function aoRedimensionar() {
redimensionar();
// o canvas limpa ao mudar de tamanho; parado, redesenha o mesmo quadro
if (pausado) renderer.render(cena, camera);
}
// Contexto WebGL perdido (GPU reiniciada, aba em segundo plano no celular): pausa até ele voltar.
let semContexto = false;
function aoPerderContexto(evento: Event) {
// sem isto o navegador não devolve o contexto
evento.preventDefault();
semContexto = true;
pausar();
}
function aoRestaurarContexto() {
semContexto = false;
// o three já refez o estado dele; o quadro parado volta e "Continuar" passa a valer
renderer.render(cena, camera);
}
window.addEventListener("resize", aoRedimensionar);
document.addEventListener("visibilitychange", aoMudarVisibilidade);
o.canvas.addEventListener("webglcontextlost", aoPerderContexto);
o.canvas.addEventListener("webglcontextrestored", aoRestaurarContexto);
publicar(performance.now(), true);
raf = requestAnimationFrame(quadro);
return {
pausar,
continuar,
descartar() {
cancelAnimationFrame(raf);
window.removeEventListener("resize", aoRedimensionar);
document.removeEventListener("visibilitychange", aoMudarVisibilidade);
o.canvas.removeEventListener("webglcontextlost", aoPerderContexto);
o.canvas.removeEventListener("webglcontextrestored", aoRestaurarContexto);
const tabela = tabelaDePbr(renderer, cena);
efeitos.descartar();
atores.descartar();
ambiente.descartar();
// a próxima partida a envia de novo, num renderer novo
tabela?.dispose();
renderer.dispose();
// CA9: o que este renderer ainda conta depois de soltar tudo; fica em `window.__jogo` para o teste ler
const { geometries, textures } = renderer.info.memory;
if (window.__jogo)
window.__jogo.sobras = { geometrias: geometries, texturas: textures };
renderer.forceContextLoss();
},
};
}
+62
View File
@@ -0,0 +1,62 @@
import type {
ArmaId,
Dificuldade,
Entrada,
Fase,
LinhaFeed,
} from "@/lib/jogo/tipos";
/** O que a pessoa escolhe na tela inicial. */
export type Escolha = {
problemaId: string;
dificuldade: Dificuldade;
};
/** O que a tela mostra por cima da cena. A cena manda um novo quando algum valor muda, no máximo 10 vezes por segundo. */
export type Retrato = {
fase: Fase;
/** Índice da horda atual: 0 a 2, e 3 no chefão. */
horda: number;
/** Id do problema da horda atual. */
problemaAtual: string;
/** Zumbis que ainda faltam cair nesta horda (na fila e à vista). */
restantes: number;
vida: number;
arma: ArmaId;
municao: number;
placar: { jogador: number; drones: number };
feed: readonly LinhaFeed[];
/** Um por drone vivo, na ordem em que nasceram. */
drones: readonly { problemaId: string; eliminacoes: number }[];
pausado: boolean;
};
/** O que sobra da partida para o relatório. */
export type Resultado = {
vitoria: boolean;
problemaId: string;
dificuldade: Dificuldade;
/** Ids dos problemas resolvidos, na ordem. */
resolvidos: readonly string[];
placar: { jogador: number; drones: number };
drones: readonly { problemaId: string; eliminacoes: number }[];
segundos: number;
};
/**
* Junta teclado, mouse e toque numa `Entrada` por passo da lógica.
* Convenção de giro (a mesma da lógica): `olhar[0]` positivo vira à esquerda, `olhar[1]` positivo olha para cima.
*/
export type Controle = {
/** A entrada deste passo. O giro acumulado desde a última leitura é devolvido e zerado. */
ler(): Entrada;
/** Direcional de toque: x para a direita, y para a frente, cada um de -1 a 1. */
definirMover(x: number, y: number): void;
/** Soma giro em radianos (toque ou mouse). */
somarOlhar(rumo: number, inclinacao: number): void;
definirAtirar(atirando: boolean): void;
definirArma(arma: ArmaId): void;
/** Solta tudo: sem movimento, sem tiro, sem giro pendente. Usado ao pausar. */
soltar(): void;
descartar(): void;
};
+243
View File
@@ -0,0 +1,243 @@
import { fireEvent } from "@testing-library/react";
import { criarControle } from "./controle";
let alvo: HTMLDivElement;
let travaDo: Element | null;
beforeEach(() => {
alvo = document.createElement("div");
document.body.append(alvo);
travaDo = null;
Object.defineProperty(document, "pointerLockElement", {
configurable: true,
get: () => travaDo,
});
});
afterEach(() => {
alvo.remove();
});
function travar() {
travaDo = alvo;
document.dispatchEvent(new Event("pointerlockchange"));
}
function mover(movementX: number, movementY: number) {
const e = new MouseEvent("mousemove");
Object.defineProperty(e, "movementX", { value: movementX });
Object.defineProperty(e, "movementY", { value: movementY });
document.dispatchEvent(e);
}
describe("criarControle: teclado", () => {
it("W e as setas movem; teclas opostas se anulam", () => {
const c = criarControle(alvo);
fireEvent.keyDown(window, { code: "KeyW" });
fireEvent.keyDown(window, { code: "ArrowRight" });
expect(c.ler().mover).toEqual([1, 1]);
fireEvent.keyDown(window, { code: "KeyS" });
fireEvent.keyDown(window, { code: "KeyA" });
expect(c.ler().mover).toEqual([0, 0]);
fireEvent.keyUp(window, { code: "KeyS" });
fireEvent.keyUp(window, { code: "KeyA" });
expect(c.ler().mover).toEqual([1, 1]);
c.descartar();
});
it("soma o direcional de toque ao teclado", () => {
const c = criarControle(alvo);
c.definirMover(-1, 0.5);
fireEvent.keyDown(window, { code: "KeyD" });
expect(c.ler().mover).toEqual([0, 0.5]);
c.descartar();
});
it("1, 2 e 3 escolhem a arma", () => {
const c = criarControle(alvo);
expect(c.ler().arma).toBe(0);
fireEvent.keyDown(window, { code: "Digit3" });
expect(c.ler().arma).toBe(2);
fireEvent.keyDown(window, { code: "Digit2" });
expect(c.ler().arma).toBe(1);
fireEvent.keyDown(window, { code: "Digit1" });
expect(c.ler().arma).toBe(0);
c.descartar();
});
it("a roda troca para a próxima e para a anterior, em volta", () => {
const c = criarControle(alvo);
fireEvent.wheel(window, { deltaY: 100 });
expect(c.ler().arma).toBe(1);
fireEvent.wheel(window, { deltaY: -100 });
fireEvent.wheel(window, { deltaY: -100 });
expect(c.ler().arma).toBe(2);
fireEvent.wheel(window, { deltaY: 100 });
expect(c.ler().arma).toBe(0);
c.descartar();
});
it("perder o foco da janela solta as teclas", () => {
const c = criarControle(alvo);
fireEvent.keyDown(window, { code: "KeyW" });
fireEvent.blur(window);
expect(c.ler().mover).toEqual([0, 0]);
c.descartar();
});
it("ignora tecla com o foco num campo de texto ou botão", () => {
const c = criarControle(alvo);
const campo = document.createElement("input");
const botao = document.createElement("button");
document.body.append(campo, botao);
fireEvent.keyDown(campo, { code: "KeyW" });
fireEvent.keyDown(botao, { code: "Digit3" });
const entrada = c.ler();
expect(entrada.mover).toEqual([0, 0]);
expect(entrada.arma).toBe(0);
campo.remove();
botao.remove();
c.descartar();
});
});
describe("criarControle: mouse", () => {
it("clicar no alvo pede a trava do ponteiro", () => {
alvo.requestPointerLock = jest.fn();
const c = criarControle(alvo);
fireEvent.click(alvo);
expect(alvo.requestPointerLock).toHaveBeenCalledTimes(1);
c.descartar();
});
it("clicar não falha onde não há trava do ponteiro", () => {
const c = criarControle(alvo);
expect(() => fireEvent.click(alvo)).not.toThrow();
c.descartar();
});
it("avisa quando a trava muda", () => {
const aoMudarTrava = jest.fn();
const c = criarControle(alvo, { aoMudarTrava });
travar();
travaDo = null;
document.dispatchEvent(new Event("pointerlockchange"));
expect(aoMudarTrava.mock.calls).toEqual([[true], [false]]);
c.descartar();
});
it("sem a trava, o mouse não gira nem atira", () => {
const c = criarControle(alvo);
mover(100, 100);
fireEvent.mouseDown(document, { button: 0 });
const entrada = c.ler();
expect(entrada.olhar).toEqual([0, 0]);
expect(entrada.atirar).toBe(false);
c.descartar();
});
it("com a trava, soma o giro e o ler o zera", () => {
const c = criarControle(alvo);
travar();
mover(100, -50);
mover(100, 0);
const [rumo, inclinacao] = c.ler().olhar;
expect(rumo).toBeCloseTo(-200 * 0.0022);
expect(inclinacao).toBeCloseTo(50 * 0.0022);
expect(c.ler().olhar).toEqual([0, 0]);
c.descartar();
});
it("botão esquerdo pressionado atira; outro botão e soltar não", () => {
const c = criarControle(alvo);
travar();
fireEvent.mouseDown(document, { button: 2 });
expect(c.ler().atirar).toBe(false);
fireEvent.mouseDown(document, { button: 0 });
expect(c.ler().atirar).toBe(true);
fireEvent.mouseUp(document, { button: 2 });
expect(c.ler().atirar).toBe(true);
fireEvent.mouseUp(document, { button: 0 });
expect(c.ler().atirar).toBe(false);
c.descartar();
});
it("clique inteiro entre duas leituras ainda dá um tiro, e só um", () => {
const c = criarControle(alvo);
travar();
fireEvent.mouseDown(document, { button: 0 });
fireEvent.mouseUp(document, { button: 0 });
expect(c.ler().atirar).toBe(true);
expect(c.ler().atirar).toBe(false);
c.descartar();
});
});
describe("criarControle: toque, soltar e descartar", () => {
it("junta o giro, o tiro e a arma vindos do toque", () => {
const c = criarControle(alvo);
c.somarOlhar(0.1, 0.2);
c.somarOlhar(0.1, 0);
c.definirAtirar(true);
c.definirArma(2);
expect(c.ler()).toEqual({
mover: [0, 0],
olhar: [0.2, 0.2],
atirar: true,
arma: 2,
});
c.descartar();
});
it("toque no botão de tiro entre duas leituras ainda dá um tiro, e só um", () => {
const c = criarControle(alvo);
c.definirAtirar(true);
c.definirAtirar(false);
expect(c.ler().atirar).toBe(true);
expect(c.ler().atirar).toBe(false);
c.descartar();
});
it("soltar zera movimento, tiro e giro, e mantém a arma", () => {
const c = criarControle(alvo);
travar();
fireEvent.keyDown(window, { code: "KeyW" });
fireEvent.mouseDown(document, { button: 0 });
c.definirMover(1, 1);
c.definirAtirar(true);
c.somarOlhar(1, 1);
c.definirArma(1);
c.soltar();
expect(c.ler()).toEqual({
mover: [0, 0],
olhar: [0, 0],
atirar: false,
arma: 1,
});
c.descartar();
});
it("descartar remove todos os ouvintes", () => {
alvo.requestPointerLock = jest.fn();
const aoMudarTrava = jest.fn();
const c = criarControle(alvo, { aoMudarTrava });
travar();
aoMudarTrava.mockClear();
c.descartar();
fireEvent.keyDown(window, { code: "KeyW" });
fireEvent.keyDown(window, { code: "Digit3" });
fireEvent.wheel(window, { deltaY: 100 });
fireEvent.mouseDown(document, { button: 0 });
mover(100, 100);
fireEvent.click(alvo);
document.dispatchEvent(new Event("pointerlockchange"));
expect(c.ler()).toEqual({
mover: [0, 0],
olhar: [0, 0],
atirar: false,
arma: 0,
});
expect(alvo.requestPointerLock).not.toHaveBeenCalled();
expect(aoMudarTrava).not.toHaveBeenCalled();
});
});
+134
View File
@@ -0,0 +1,134 @@
import type { ArmaId, Entrada } from "@/lib/jogo/tipos";
import type { Controle } from "./contrato";
const SENSIBILIDADE = 0.0022;
const CAMPOS = "input, textarea, select, button, [contenteditable]";
const ARMAS_NA_ORDEM = 3;
/** Teclas de movimento: [direita, frente]. */
const DIRECAO: Record<string, readonly [number, number]> = {
KeyW: [0, 1],
ArrowUp: [0, 1],
KeyS: [0, -1],
ArrowDown: [0, -1],
KeyD: [1, 0],
ArrowRight: [1, 0],
KeyA: [-1, 0],
ArrowLeft: [-1, 0],
};
const ARMA_DA_TECLA: Record<string, ArmaId> = {
Digit1: 0,
Digit2: 1,
Digit3: 2,
};
export function criarControle(
alvo: HTMLElement,
opcoes: { aoMudarTrava?: (travado: boolean) => void } = {},
): Controle {
const teclas = new Set<string>();
let toque: [number, number] = [0, 0];
let giro: [number, number] = [0, 0];
let mouseAtira = false;
let toqueAtira = false;
// apertou desde a última leitura: um clique inteiro entre dois passos ainda vira um tiro
let apertou = false;
let arma: ArmaId = 0;
const travado = () => document.pointerLockElement === alvo;
const aoApertar = (e: KeyboardEvent) => {
if (e.target instanceof Element && e.target.closest(CAMPOS)) return;
if (e.code in ARMA_DA_TECLA) arma = ARMA_DA_TECLA[e.code];
if (e.code in DIRECAO) {
teclas.add(e.code);
e.preventDefault();
}
};
const aoSoltar = (e: KeyboardEvent) => {
teclas.delete(e.code);
};
const aoPerderFoco = () => teclas.clear();
const aoRolar = (e: WheelEvent) => {
const passo = e.deltaY > 0 ? 1 : -1;
arma = ((arma + passo + ARMAS_NA_ORDEM) % ARMAS_NA_ORDEM) as ArmaId;
};
const aoClicar = () => alvo.requestPointerLock?.();
const aoMover = (e: MouseEvent) => {
if (!travado()) return;
giro = [
giro[0] - e.movementX * SENSIBILIDADE,
giro[1] - e.movementY * SENSIBILIDADE,
];
};
const aoApertarMouse = (e: MouseEvent) => {
if (e.button !== 0 || !travado()) return;
mouseAtira = true;
apertou = true;
};
const aoSoltarMouse = (e: MouseEvent) => {
if (e.button === 0) mouseAtira = false;
};
const aoMudarTrava = () => opcoes.aoMudarTrava?.(travado());
window.addEventListener("keydown", aoApertar);
window.addEventListener("keyup", aoSoltar);
window.addEventListener("blur", aoPerderFoco);
window.addEventListener("wheel", aoRolar);
alvo.addEventListener("click", aoClicar);
document.addEventListener("mousemove", aoMover);
document.addEventListener("mousedown", aoApertarMouse);
document.addEventListener("mouseup", aoSoltarMouse);
document.addEventListener("pointerlockchange", aoMudarTrava);
const soltar = () => {
teclas.clear();
toque = [0, 0];
giro = [0, 0];
mouseAtira = false;
toqueAtira = false;
apertou = false;
};
return {
ler(): Entrada {
let x = toque[0];
let y = toque[1];
for (const t of teclas) {
x += DIRECAO[t][0];
y += DIRECAO[t][1];
}
const olhar = giro;
giro = [0, 0];
const atirar = mouseAtira || toqueAtira || apertou;
apertou = false;
return { mover: [x, y], olhar, atirar, arma };
},
definirMover: (x, y) => {
toque = [x, y];
},
somarOlhar: (rumo, inclinacao) => {
giro = [giro[0] + rumo, giro[1] + inclinacao];
},
definirAtirar: (atirando) => {
if (atirando && !toqueAtira) apertou = true;
toqueAtira = atirando;
},
definirArma: (nova) => {
arma = nova;
},
soltar,
descartar: () => {
window.removeEventListener("keydown", aoApertar);
window.removeEventListener("keyup", aoSoltar);
window.removeEventListener("blur", aoPerderFoco);
window.removeEventListener("wheel", aoRolar);
alvo.removeEventListener("click", aoClicar);
document.removeEventListener("mousemove", aoMover);
document.removeEventListener("mousedown", aoApertarMouse);
document.removeEventListener("mouseup", aoSoltarMouse);
document.removeEventListener("pointerlockchange", aoMudarTrava);
},
};
}
+314
View File
@@ -0,0 +1,314 @@
import {
BufferGeometry,
type InstancedMesh,
Matrix4,
type Mesh,
type Object3D,
PerspectiveCamera,
type PointLight,
Scene,
} from "three";
import { criarPartida } from "@/lib/jogo/partida";
import { PROBLEMAS } from "@/lib/jogo/problemas";
import type { Evento, Partida, Zumbi } from "@/lib/jogo/tipos";
import { criarEfeitos } from "./efeitos";
import type { Som } from "./som";
// O jsdom não desenha em <canvas>: um contexto falso aceita qualquer chamada.
const contextoFalso: unknown = new Proxy(
{},
{
get: () => () => ({ addColorStop() {} }),
set: () => true,
},
);
beforeAll(() => {
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockImplementation((() => contextoFalso) as never);
});
afterAll(() => jest.restoreAllMocks());
function somFalso(): Som & { tocar: jest.Mock } {
return {
liberar: jest.fn(() => Promise.resolve()),
tocar: jest.fn(),
definirMudo: jest.fn(),
descartar: jest.fn(),
};
}
function montar(movimentoReduzido = false) {
const cena = new Scene();
const camera = new PerspectiveCamera(70, 1, 0.05, 100);
cena.add(camera);
const p: Partida = criarPartida({
semente: 1,
dificuldade: "normal",
problemaId: PROBLEMAS[0].id,
});
camera.position.set(p.jogador.x, 1.6, p.jogador.z);
const som = somFalso();
const efeitos = criarEfeitos(cena, camera, { movimentoReduzido, som });
return { cena, camera, p, som, efeitos };
}
function zumbi(p: Partida): Zumbi {
const z: Zumbi = {
id: 7,
tipo: "comum",
problemaId: PROBLEMAS[0].id,
x: p.jogador.x,
z: p.jogador.z + 4,
rumo: Math.PI,
vida: 100,
estado: "andando",
tempo: 0,
};
p.zumbis.push(z);
return z;
}
function acerto(z: Zumbi, cabeca = false): Evento {
return { tipo: "acerto", zumbiId: z.id, cabeca, x: z.x, y: 1.2, z: z.z };
}
const tiro: Evento = { tipo: "tiro", autor: "jogador", arma: 0, distancia: 4 };
/** Instâncias com escala diferente de zero. */
function visiveis(malha: InstancedMesh): number {
const m = new Matrix4();
let n = 0;
for (let i = 0; i < malha.count; i++) {
malha.getMatrixAt(i, m);
if (m.determinant() !== 0) n++;
}
return n;
}
function achar<T extends Object3D>(cena: Scene, nome: string): T {
const o = cena.getObjectByName(nome);
if (!o) throw new Error(`sem ${nome}`);
return o as T;
}
function desenhados(raiz: Object3D): number {
let n = 0;
raiz.traverseVisible((o) => {
if ((o as Mesh).isMesh) n++;
});
return n;
}
it("200 acertos seguidos não passam de 64 gotas nem de 64 marcas", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
for (let i = 0; i < 200; i++) efeitos.aoEvento(acerto(z, i % 2 === 0), p);
const gotas = achar<InstancedMesh>(cena, "efeitos-gotas");
const marcas = achar<InstancedMesh>(cena, "efeitos-marcas");
efeitos.avancar(0.016, p);
expect(gotas.count).toBe(64);
expect(visiveis(gotas)).toBe(64);
expect(marcas.count).toBe(64);
expect(visiveis(marcas)).toBe(64);
});
it("as gotas somem depois de 0,5 s; tiro na cabeça espirra o dobro", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
const gotas = achar<InstancedMesh>(cena, "efeitos-gotas");
efeitos.aoEvento(acerto(z), p);
efeitos.avancar(0.3, p);
expect(visiveis(gotas)).toBe(6);
efeitos.avancar(0.25, p);
expect(visiveis(gotas)).toBe(0);
efeitos.aoEvento(acerto(z, true), p);
efeitos.avancar(0.016, p);
expect(visiveis(gotas)).toBe(12);
});
it("a marca fica no chão embaixo do zumbi e a morte deixa uma poça maior", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
const marcas = achar<InstancedMesh>(cena, "efeitos-marcas");
efeitos.aoEvento(acerto(z), p);
efeitos.aoEvento({ tipo: "morte", zumbiId: z.id, autor: "jogador" }, p);
efeitos.avancar(2, p);
const m = new Matrix4();
marcas.getMatrixAt(0, m);
const e = m.elements;
expect(e[13]).toBeLessThan(0.05);
expect(Math.abs(e[12] - z.x)).toBeLessThan(0.3);
expect(Math.abs(e[14] - z.z)).toBeLessThan(0.3);
const tamanho = (i: number) => {
marcas.getMatrixAt(i, m);
return Math.hypot(m.elements[0], m.elements[2]);
};
expect(tamanho(1)).toBeGreaterThan(tamanho(0));
});
it("tiro do jogador liga o clarão, a luz e o traçante, que apagam depois de 60 ms", () => {
const { cena, p, som, efeitos } = montar();
const clarao = achar<Mesh>(cena, "efeitos-clarao");
const luz = achar<PointLight>(cena, "efeitos-luz");
const tracantes = achar<InstancedMesh>(cena, "efeitos-tracantes");
expect(clarao.visible).toBe(false);
efeitos.aoEvento(tiro, p);
expect(som.tocar).toHaveBeenCalledWith("pistola", { variar: true });
efeitos.avancar(0.03, p);
expect(clarao.visible).toBe(true);
expect(luz.intensity).toBeGreaterThan(0);
expect(visiveis(tracantes)).toBe(1);
efeitos.avancar(0.04, p);
expect(clarao.visible).toBe(false);
expect(luz.intensity).toBe(0);
expect(visiveis(tracantes)).toBe(0);
});
it("o traçante do drone sai dele, a 2,3 m de altura, com som baixo", () => {
const { cena, p, som, efeitos } = montar();
p.drones.push({
id: 3,
problemaId: PROBLEMAS[0].id,
x: 2,
z: 2,
rumo: 0,
recarga: 0,
eliminacoes: 0,
});
efeitos.aoEvento({ tipo: "tiro", autor: 3, arma: "drone", distancia: 6 }, p);
expect(som.tocar).toHaveBeenCalledWith("drone", {
volume: 0.35,
variar: true,
});
const tracantes = achar<InstancedMesh>(cena, "efeitos-tracantes");
const m = new Matrix4();
tracantes.getMatrixAt(0, m);
// O meio da faixa fica entre o drone (z = 2) e o ponto do tiro (z = 8).
expect(m.elements[14]).toBeCloseTo(5, 5);
expect(m.elements[13]).toBeCloseTo((2.3 + 1.2) / 2, 5);
});
it("no máximo 8 traçantes e 6 chamadas de desenho somando tudo", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
const antes = desenhados(cena);
for (let i = 0; i < 20; i++) {
efeitos.aoEvento(tiro, p);
efeitos.aoEvento(acerto(z), p);
}
efeitos.aoEvento({ tipo: "morte", zumbiId: z.id, autor: "jogador" }, p);
efeitos.avancar(0.016, p);
expect(visiveis(achar<InstancedMesh>(cena, "efeitos-tracantes"))).toBe(8);
expect(desenhados(cena)).toBeLessThanOrEqual(6);
expect(antes).toBeLessThanOrEqual(6);
});
it("troca de arma troca o modelo visível", () => {
const { cena, p, efeitos } = montar();
const visivel = () =>
["pistola", "automatica", "faca"].filter(
(nome) => achar<Mesh>(cena, `arma-${nome}`).visible,
);
efeitos.avancar(0.016, p);
expect(visivel()).toEqual(["pistola"]);
p.jogador.arma = 1;
efeitos.avancar(0.016, p);
expect(visivel()).toEqual(["automatica"]);
p.jogador.arma = 2;
efeitos.avancar(0.016, p);
expect(visivel()).toEqual(["faca"]);
});
it("o golpe dá um tranco na câmera por 150 ms e toca o som", () => {
const { camera, p, som, efeitos } = montar();
const x = camera.position.x;
const y = camera.position.y;
efeitos.aoEvento({ tipo: "golpe", dano: 10, zumbiId: 0 }, p);
expect(som.tocar).toHaveBeenCalledWith("golpe");
efeitos.avancar(0.016, p);
expect(
camera.position.x !== x ||
camera.position.y !== y ||
camera.rotation.z !== 0,
).toBe(true);
efeitos.avancar(0.2, p);
expect(camera.position.x).toBeCloseTo(x, 10);
expect(camera.position.y).toBeCloseTo(y, 10);
expect(camera.rotation.z).toBeCloseTo(0, 10);
});
it("movimento reduzido não mexe a câmera no golpe nem balança a arma", () => {
const { cena, camera, p, efeitos } = montar(true);
camera.updateMatrix();
const inicial = camera.matrix.clone();
efeitos.aoEvento({ tipo: "golpe", dano: 10, zumbiId: 0 }, p);
const arma = achar<Object3D>(cena, "efeitos-arma");
const posicaoDaArma = arma.position.clone();
for (let i = 0; i < 20; i++) {
p.jogador.z += 0.07;
efeitos.avancar(0.016, p);
camera.updateMatrix();
expect(camera.matrix.equals(inicial)).toBe(true);
}
expect(arma.position.equals(posicaoDaArma)).toBe(true);
});
it("os eventos tocam os sons certos", () => {
const { p, som, efeitos } = montar();
const z = zumbi(p);
efeitos.aoEvento(
{ tipo: "tiro", autor: "jogador", arma: 2, distancia: 1 },
p,
);
efeitos.aoEvento(acerto(z), p);
efeitos.aoEvento({ tipo: "resolvido", problemaId: "x", droneId: 1 }, p);
efeitos.aoEvento({ tipo: "caixa", balas: 30 }, p);
const nomes = som.tocar.mock.calls.map((c) => c[0]);
expect(nomes).toEqual(["faca", "acerto", "resolvido", "caixa"]);
});
it("um zumbi a até 12 m geme a cada 2 a 5 s, mais baixo quando longe", () => {
const { p, som, efeitos } = montar();
const z = zumbi(p);
z.z = p.jogador.z + 9;
for (let i = 0; i < 300; i++) efeitos.avancar(0.02, p);
const gemidos = som.tocar.mock.calls.filter((c) =>
String(c[0]).startsWith("gemido"),
);
expect(gemidos.length).toBeGreaterThanOrEqual(1);
expect(gemidos.length).toBeLessThanOrEqual(3);
expect(gemidos[0][1].volume).toBeCloseTo(0.25, 5);
som.tocar.mockClear();
z.z = p.jogador.z + 13;
for (let i = 0; i < 300; i++) efeitos.avancar(0.02, p);
expect(som.tocar).not.toHaveBeenCalled();
});
it("descartar tira tudo da cena e da câmera e libera a memória", () => {
const { cena, camera, p, efeitos } = montar();
const z = zumbi(p);
efeitos.aoEvento(tiro, p);
efeitos.aoEvento(acerto(z), p);
const liberadas = jest.spyOn(BufferGeometry.prototype, "dispose");
efeitos.descartar();
expect(camera.children).toHaveLength(0);
expect(cena.children).toEqual([camera]);
expect(liberadas.mock.calls.length).toBeGreaterThanOrEqual(7);
liberadas.mockRestore();
});
it("sem câmera na cena, coloca e depois retira", () => {
const cena = new Scene();
const camera = new PerspectiveCamera();
const efeitos = criarEfeitos(cena, camera, {
movimentoReduzido: false,
som: somFalso(),
});
expect(camera.parent).toBe(cena);
efeitos.descartar();
expect(cena.children).toHaveLength(0);
expect(camera.parent).toBeNull();
});
+714
View File
@@ -0,0 +1,714 @@
/**
* Efeitos da cena: arma na tela, clarão do cano, traçante, sangue e os sons ligados aos eventos da lógica.
* Tudo em reserva fixa e sem alocação por quadro em `avancar`. São 5 chamadas de desenho no total:
* arma, clarão, traçantes, gotas e marcas.
*/
import {
AdditiveBlending,
BoxGeometry,
BufferGeometry,
CanvasTexture,
Color,
DynamicDrawUsage,
Float32BufferAttribute,
Group,
InstancedMesh,
Matrix4,
Mesh,
MeshBasicMaterial,
MeshStandardMaterial,
type PerspectiveCamera,
PlaneGeometry,
PointLight,
Quaternion,
type Scene,
Vector3,
} from "three";
import type { ArmaId, Evento, Partida } from "@/lib/jogo/tipos";
import { JOGADOR } from "@/lib/jogo/valores";
import type { Som } from "./som";
export type Efeitos = {
aoEvento(evento: Evento, p: Partida): void;
/** `dt` em segundos. */
avancar(dt: number, p: Partida): void;
descartar(): void;
};
/** Durações em segundos. */
const CLARAO = 0.06;
const TRACANTE = 0.06;
const VIDA_DA_GOTA = 0.5;
const TRANCO = 0.15;
const CRESCIMENTO_DA_POCA = 1.2;
const MAX_TRACANTES = 8;
const MAX_GOTAS = 64;
const MAX_MARCAS = 64;
const GRAVIDADE = 9.8;
/** A mesma altura do modelo do drone em `atores.ts`. */
const ALTURA_DO_DRONE = 2.3;
/** A mesma altura de acerto do drone em `lib/jogo/drones.ts`. */
const ALTURA_DO_ACERTO_DO_DRONE = 1.2;
const ALCANCE_DO_GEMIDO = 12;
const ESPESSURA_DO_TRACANTE = 0.012;
const COR_DO_FERROLHO = 0x464a51;
const COR_DA_ARMACAO = 0x2c2f34;
const COR_ESCURA = 0x17181b;
const COR_DA_LAMINA = 0xb4bac2;
const COR_DO_FIO = 0xdde2e8;
const COR_DA_GOTA = 0x6a0a0a;
const COR_DA_MARCA = 0x3b0505;
const COR_DO_FOGO = 0xffc46b;
/** Onde a arma fica no espaço da câmera (x à direita, y para cima, −z para a frente): baixa, no canto direito. */
const POSICAO_DA_ARMA = new Vector3(0.2, -0.21, -0.44);
/** Giro de repouso em y: o cano aponta um pouco para dentro, na direção da mira. */
const GIRO_DA_ARMA = 0.07;
/** Uma caixa do modelo: largura, altura, profundidade, centro (x, y, z), cor e giro em x. */
type Peca = readonly [
number,
number,
number,
number,
number,
number,
number,
number?,
];
/** Pistola de 19 cm: ferrolho, cano, armação, guarda-mato e cabo inclinado. */
const PISTOLA: readonly Peca[] = [
[0.026, 0.03, 0.186, 0, 0.024, -0.068, COR_DO_FERROLHO],
[0.012, 0.012, 0.008, 0, 0.026, -0.163, COR_ESCURA],
[0.002, 0.011, 0.04, 0.0132, 0.029, -0.04, COR_ESCURA],
[0.018, 0.006, 0.006, 0, 0.042, 0.018, COR_ESCURA],
[0.004, 0.006, 0.005, 0, 0.042, -0.154, COR_ESCURA],
[0.024, 0.018, 0.16, 0, 0, -0.06, COR_DA_ARMACAO],
[0.012, 0.006, 0.052, 0, -0.03, -0.058, COR_DA_ARMACAO],
[0.012, 0.03, 0.007, 0, -0.016, -0.084, COR_DA_ARMACAO],
[0.005, 0.018, 0.005, 0, -0.014, -0.05, COR_ESCURA, 0.2],
[0.028, 0.105, 0.048, 0, -0.055, 0.008, COR_DA_ARMACAO, -0.32],
[0.026, 0.012, 0.02, 0, -0.004, 0.03, COR_DA_ARMACAO],
[0.03, 0.01, 0.052, 0, -0.108, 0.027, COR_ESCURA, -0.32],
];
/** Automática: mais comprida, com carregador e coronha curta vazada. */
const AUTOMATICA: readonly Peca[] = [
[0.034, 0.05, 0.24, 0, 0.02, -0.08, COR_DO_FERROLHO],
[0.014, 0.008, 0.22, 0, 0.049, -0.1, COR_ESCURA],
[0.02, 0.016, 0.02, 0, 0.06, 0.01, COR_ESCURA],
[0.006, 0.02, 0.008, 0, 0.056, -0.29, COR_ESCURA],
[0.032, 0.04, 0.11, 0, 0.015, -0.255, COR_DA_ARMACAO],
[0.014, 0.014, 0.06, 0, 0.02, -0.34, COR_ESCURA],
[0.02, 0.02, 0.03, 0, 0.02, -0.385, COR_ESCURA],
[0.022, 0.12, 0.04, 0, -0.06, -0.13, COR_ESCURA, 0.15],
[0.028, 0.09, 0.04, 0, -0.04, 0, COR_DA_ARMACAO, -0.3],
[0.012, 0.006, 0.05, 0, -0.022, -0.045, COR_DA_ARMACAO],
[0.008, 0.008, 0.12, 0, 0.03, 0.1, COR_ESCURA],
[0.008, 0.008, 0.12, 0, -0.01, 0.1, COR_ESCURA],
[0.03, 0.07, 0.012, 0, 0.01, 0.16, COR_DA_ARMACAO],
];
/** Faca: lâmina de aço com fio mais claro, guarda e cabo escuro. */
const FACA: readonly Peca[] = [
[0.024, 0.03, 0.11, 0, -0.005, 0.02, COR_ESCURA],
[0.012, 0.05, 0.012, 0, 0, -0.04, COR_DO_FERROLHO],
[0.004, 0.03, 0.16, 0, 0.002, -0.126, COR_DA_LAMINA],
[0.0042, 0.006, 0.16, 0, -0.011, -0.126, COR_DO_FIO],
[0.004, 0.018, 0.04, 0, 0.008, -0.222, COR_DA_LAMINA],
];
const MODELOS = [PISTOLA, AUTOMATICA, FACA] as const;
const NOMES_DOS_MODELOS = ["pistola", "automatica", "faca"] as const;
/** Boca do cano de cada arma, no espaço do grupo da arma. */
const BOCAIS: readonly Vector3[] = [
new Vector3(0, 0.026, -0.17),
new Vector3(0, 0.02, -0.405),
new Vector3(0, 0, -0.25),
];
const TAMANHO_DO_CLARAO = [0.1, 0.13, 0];
/** Coice por arma: recuo em z, giro em x e em y. A faca avança e corta de lado. */
const COICES: readonly (readonly [number, number, number])[] = [
[0.045, 0.16, 0],
[0.02, 0.05, 0],
[-0.09, 0.1, 0.6],
];
const SONS_DAS_ARMAS = ["pistola", "automatica", "faca"] as const;
/** Junta caixas numa geometria só, com a cor em cada vértice: uma chamada de desenho por arma. */
function montarModelo(pecas: readonly Peca[]): BufferGeometry {
const posicoes: number[] = [];
const normais: number[] = [];
const cores: number[] = [];
const indices: number[] = [];
const cor = new Color();
for (const [l, a, pr, x, y, z, hex, giro] of pecas) {
const caixa = new BoxGeometry(l, a, pr);
if (giro) caixa.rotateX(giro);
caixa.translate(x, y, z);
const base = posicoes.length / 3;
const posicao = caixa.getAttribute("position");
posicoes.push(...(posicao.array as Float32Array));
normais.push(...(caixa.getAttribute("normal").array as Float32Array));
cor.setHex(hex);
for (let i = 0; i < posicao.count; i++) cores.push(cor.r, cor.g, cor.b);
const indice = caixa.getIndex();
if (indice) for (const i of indice.array) indices.push(base + i);
caixa.dispose();
}
const geometria = new BufferGeometry();
geometria.setAttribute("position", new Float32BufferAttribute(posicoes, 3));
geometria.setAttribute("normal", new Float32BufferAttribute(normais, 3));
geometria.setAttribute("color", new Float32BufferAttribute(cores, 3));
geometria.setIndex(indices);
return geometria;
}
function criarTela(): [HTMLCanvasElement, CanvasRenderingContext2D | null] {
const tela = document.createElement("canvas");
tela.width = 64;
tela.height = 64;
return [tela, tela.getContext("2d")];
}
/** Mancha irregular branca com gotas em volta; a cor vem do material. */
function texturaDeRespingo(): CanvasTexture {
const [tela, ctx] = criarTela();
if (ctx) {
ctx.fillStyle = "#ffffff";
for (let i = 0; i < 10; i++) {
const angulo = (i / 10) * Math.PI * 2;
const r = 4 + Math.random() * 6;
ctx.beginPath();
ctx.arc(
32 + Math.cos(angulo) * r,
32 + Math.sin(angulo) * r,
9 + Math.random() * 6,
0,
Math.PI * 2,
);
ctx.fill();
}
for (let i = 0; i < 8; i++) {
const angulo = Math.random() * Math.PI * 2;
const r = 18 + Math.random() * 9;
ctx.beginPath();
ctx.arc(
32 + Math.cos(angulo) * r,
32 + Math.sin(angulo) * r,
1.5 + Math.random() * 2.5,
0,
Math.PI * 2,
);
ctx.fill();
}
}
return new CanvasTexture(tela);
}
/** Brilho redondo com uma cruz fina, para o clarão do cano. */
function texturaDeClarao(): CanvasTexture {
const [tela, ctx] = criarTela();
if (ctx) {
const brilho = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
brilho.addColorStop(0, "rgba(255,255,255,1)");
brilho.addColorStop(0.3, "rgba(255,255,255,0.45)");
brilho.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = brilho;
ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = "rgba(255,255,255,0.8)";
ctx.fillRect(4, 31, 56, 2);
ctx.fillRect(31, 4, 2, 56);
}
return new CanvasTexture(tela);
}
function criarReserva(
geometria: BufferGeometry,
material: MeshBasicMaterial,
quantidade: number,
nome: string,
): InstancedMesh<BufferGeometry, MeshBasicMaterial> {
const malha = new InstancedMesh(geometria, material, quantidade);
malha.name = nome;
malha.instanceMatrix.setUsage(DynamicDrawUsage);
// As instâncias andam pelo mapa todo; a esfera calculada uma vez cortaria as que saem dela.
malha.frustumCulled = false;
for (let i = 0; i < quantidade; i++) malha.setMatrixAt(i, ESCONDIDA);
return malha;
}
const ESCONDIDA = new Matrix4().makeScale(0, 0, 0);
const EIXO_Y = new Vector3(0, 1, 0);
const EIXO_Z = new Vector3(0, 0, 1);
/** Reaproveitado a cada gemido para não alocar dentro de `avancar`. */
const OPCOES_DO_GEMIDO = { volume: 1, variar: true };
export function criarEfeitos(
cena: Scene,
camera: PerspectiveCamera,
opcoes: { movimentoReduzido: boolean; som: Som },
): Efeitos {
const { movimentoReduzido, som } = opcoes;
// Filhos da câmera só aparecem se a câmera estiver na cena.
const colocouCamera = camera.parent === null;
if (colocouCamera) cena.add(camera);
// Arma na tela.
const materialDaArma = new MeshStandardMaterial({
vertexColors: true,
metalness: 0.45,
roughness: 0.62,
});
const arma = new Group();
arma.name = "efeitos-arma";
arma.position.copy(POSICAO_DA_ARMA);
const modelos = MODELOS.map((pecas, i) => {
const modelo = new Mesh(montarModelo(pecas), materialDaArma);
modelo.name = `arma-${NOMES_DOS_MODELOS[i]}`;
modelo.visible = i === 0;
arma.add(modelo);
return modelo;
});
let armaAtual: ArmaId = 0;
const texturaDoClarao = texturaDeClarao();
const clarao = new Mesh(
new PlaneGeometry(1, 1),
new MeshBasicMaterial({
map: texturaDoClarao,
color: COR_DO_FOGO,
blending: AdditiveBlending,
transparent: true,
depthWrite: false,
toneMapped: false,
}),
);
clarao.name = "efeitos-clarao";
clarao.visible = false;
// A luz fica sempre na cena e só muda de intensidade: ligar e desligar luzes recompila os materiais.
const luz = new PointLight(COR_DO_FOGO, 0, 6, 2);
luz.name = "efeitos-luz";
arma.add(clarao, luz);
camera.add(arma);
// Efeitos no mundo.
const mundo = new Group();
mundo.name = "efeitos";
cena.add(mundo);
const tracantes = criarReserva(
new BoxGeometry(1, 1, 1),
new MeshBasicMaterial({
color: 0xfff0b0,
blending: AdditiveBlending,
transparent: true,
depthWrite: false,
toneMapped: false,
}),
MAX_TRACANTES,
"efeitos-tracantes",
);
const respingo = texturaDeRespingo();
const gotas = criarReserva(
new PlaneGeometry(1, 1),
new MeshBasicMaterial({
map: respingo,
color: COR_DA_GOTA,
transparent: true,
depthWrite: false,
}),
MAX_GOTAS,
"efeitos-gotas",
);
const marcas = criarReserva(
new PlaneGeometry(1, 1).rotateX(-Math.PI / 2),
new MeshBasicMaterial({
map: respingo,
color: COR_DA_MARCA,
transparent: true,
opacity: 0.9,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -4,
}),
MAX_MARCAS,
"efeitos-marcas",
);
const cor = new Color();
for (let i = 0; i < MAX_TRACANTES; i++)
tracantes.setColorAt(i, cor.setScalar(1));
mundo.add(marcas, gotas, tracantes);
// Estado em reserva fixa.
const vidaDoTracante = new Float32Array(MAX_TRACANTES);
let proximoTracante = 0;
let tracantesVivos = 0;
const posicaoDaGota = new Float32Array(MAX_GOTAS * 3);
const velocidadeDaGota = new Float32Array(MAX_GOTAS * 3);
const vidaDaGota = new Float32Array(MAX_GOTAS);
const tamanhoDaGota = new Float32Array(MAX_GOTAS);
let proximaGota = 0;
let gotasVivas = 0;
/** x, z, ângulo e tamanho final de cada marca; `crescimento` vai de 0 a 1. */
const marca = new Float32Array(MAX_MARCAS * 4);
const crescimento = new Float32Array(MAX_MARCAS).fill(1);
let proximaMarca = 0;
let marcasCrescendo = 0;
let restoDoClarao = 0;
let coice = 0;
let tranco = 0;
let fase = 0;
let amplitude = 0;
let ultimoX = Number.NaN;
let ultimoZ = Number.NaN;
let origemX = 0;
let origemZ = 0;
let proximoGemido = 2 + Math.random() * 3;
const trancoAplicado = new Vector3();
let rolAplicado = 0;
// Temporários reaproveitados.
const a = new Vector3();
const b = new Vector3();
const escala = new Vector3();
const direcao = new Vector3();
const meio = new Vector3();
const giro = new Quaternion();
const matriz = new Matrix4();
function trocarArma(nova: ArmaId): void {
modelos[armaAtual].visible = false;
modelos[nova].visible = true;
armaAtual = nova;
clarao.position.copy(BOCAIS[nova]);
luz.position.copy(BOCAIS[nova]);
}
trocarArma(0);
function lancarTracante(de: Vector3, ate: Vector3): void {
const i = proximoTracante;
proximoTracante = (i + 1) % MAX_TRACANTES;
if (vidaDoTracante[i] <= 0) tracantesVivos++;
vidaDoTracante[i] = TRACANTE;
direcao.subVectors(ate, de);
const comprimento = direcao.length();
if (comprimento < 1e-3) tracantes.setMatrixAt(i, ESCONDIDA);
else {
giro.setFromUnitVectors(EIXO_Z, direcao.divideScalar(comprimento));
escala.set(ESPESSURA_DO_TRACANTE, ESPESSURA_DO_TRACANTE, comprimento);
meio.addVectors(de, ate).multiplyScalar(0.5);
tracantes.setMatrixAt(i, matriz.compose(meio, giro, escala));
}
tracantes.setColorAt(i, cor.setScalar(1));
tracantes.instanceMatrix.needsUpdate = true;
if (tracantes.instanceColor) tracantes.instanceColor.needsUpdate = true;
}
function pintarMarca(i: number): void {
const k = i * 4;
const s = marca[k + 3] * (0.25 + 0.75 * crescimento[i]);
a.set(marca[k], 0.012, marca[k + 1]);
giro.setFromAxisAngle(EIXO_Y, marca[k + 2]);
escala.set(s, 1, s);
marcas.setMatrixAt(i, matriz.compose(a, giro, escala));
marcas.instanceMatrix.needsUpdate = true;
}
function deixarMarca(
x: number,
z: number,
tamanho: number,
crescer: boolean,
): void {
const i = proximaMarca;
proximaMarca = (i + 1) % MAX_MARCAS;
const k = i * 4;
marca[k] = x;
marca[k + 1] = z;
marca[k + 2] = Math.random() * Math.PI * 2;
marca[k + 3] = tamanho;
if (crescimento[i] < 1) marcasCrescendo--;
crescimento[i] = crescer ? 0 : 1;
if (crescer) marcasCrescendo++;
pintarMarca(i);
}
function zumbiPorId(p: Partida, id: number) {
for (const zumbi of p.zumbis) if (zumbi.id === id) return zumbi;
return undefined;
}
function espirrar(x: number, y: number, z: number, quantidade: number): void {
let dx = x - origemX;
let dz = z - origemZ;
const d = Math.hypot(dx, dz);
if (d > 1e-3) {
dx /= d;
dz /= d;
}
for (let n = 0; n < quantidade; n++) {
const i = proximaGota;
proximaGota = (i + 1) % MAX_GOTAS;
if (vidaDaGota[i] <= 0) gotasVivas++;
vidaDaGota[i] = VIDA_DA_GOTA;
tamanhoDaGota[i] = 0.03 + Math.random() * 0.05;
const velocidade = 1.2 + Math.random() * 2.3;
const k = i * 3;
posicaoDaGota[k] = x;
posicaoDaGota[k + 1] = y;
posicaoDaGota[k + 2] = z;
velocidadeDaGota[k] = dx * velocidade + (Math.random() - 0.5) * 1.6;
velocidadeDaGota[k + 1] = 0.6 + Math.random() * 1.8;
velocidadeDaGota[k + 2] = dz * velocidade + (Math.random() - 0.5) * 1.6;
}
}
function atirou(evento: Extract<Evento, { tipo: "tiro" }>, p: Partida): void {
if (evento.autor === "jogador") {
const j = p.jogador;
origemX = j.x;
origemZ = j.z;
const qual = evento.arma === "drone" ? armaAtual : evento.arma;
if (qual !== armaAtual) trocarArma(qual);
coice = 1;
som.tocar(SONS_DAS_ARMAS[qual], { variar: true });
if (qual === 2) return;
restoDoClarao = CLARAO;
clarao.visible = true;
clarao.rotation.z = Math.random() * Math.PI;
clarao.scale.setScalar(
TAMANHO_DO_CLARAO[qual] * (0.8 + Math.random() * 0.4),
);
luz.intensity = 4;
camera.updateMatrixWorld();
a.copy(BOCAIS[qual]).applyMatrix4(arma.matrixWorld);
const t = evento.distancia;
b.set(
j.x + Math.sin(j.rumo) * t,
JOGADOR.olhos + Math.tan(j.inclinacao) * t,
j.z + Math.cos(j.rumo) * t,
);
lancarTracante(a, b);
return;
}
som.tocar("drone", { volume: 0.35, variar: true });
const drone = p.drones.find((d) => d.id === evento.autor);
if (!drone) return;
origemX = drone.x;
origemZ = drone.z;
const t = evento.distancia;
a.set(drone.x, ALTURA_DO_DRONE, drone.z);
b.set(
drone.x + Math.sin(drone.rumo) * t,
ALTURA_DO_ACERTO_DO_DRONE,
drone.z + Math.cos(drone.rumo) * t,
);
lancarTracante(a, b);
}
function avancarTracantes(dt: number): void {
if (tracantesVivos === 0) return;
for (let i = 0; i < MAX_TRACANTES; i++) {
if (vidaDoTracante[i] <= 0) continue;
vidaDoTracante[i] -= dt;
if (vidaDoTracante[i] <= 0) {
tracantesVivos--;
tracantes.setMatrixAt(i, ESCONDIDA);
} else
tracantes.setColorAt(i, cor.setScalar(vidaDoTracante[i] / TRACANTE));
}
tracantes.instanceMatrix.needsUpdate = true;
if (tracantes.instanceColor) tracantes.instanceColor.needsUpdate = true;
}
function avancarGotas(dt: number): void {
if (gotasVivas === 0) return;
for (let i = 0; i < MAX_GOTAS; i++) {
if (vidaDaGota[i] <= 0) continue;
vidaDaGota[i] -= dt;
if (vidaDaGota[i] <= 0) {
gotasVivas--;
gotas.setMatrixAt(i, ESCONDIDA);
continue;
}
const k = i * 3;
velocidadeDaGota[k + 1] -= GRAVIDADE * dt;
posicaoDaGota[k] += velocidadeDaGota[k] * dt;
posicaoDaGota[k + 1] += velocidadeDaGota[k + 1] * dt;
posicaoDaGota[k + 2] += velocidadeDaGota[k + 2] * dt;
if (posicaoDaGota[k + 1] < 0.02) {
posicaoDaGota[k + 1] = 0.02;
velocidadeDaGota[k] = 0;
velocidadeDaGota[k + 1] = 0;
velocidadeDaGota[k + 2] = 0;
}
const s = tamanhoDaGota[i] * (0.5 + vidaDaGota[i] / VIDA_DA_GOTA);
a.set(posicaoDaGota[k], posicaoDaGota[k + 1], posicaoDaGota[k + 2]);
escala.set(s, s, s);
gotas.setMatrixAt(i, matriz.compose(a, camera.quaternion, escala));
}
gotas.instanceMatrix.needsUpdate = true;
}
function avancarMarcas(dt: number): void {
if (marcasCrescendo === 0) return;
for (let i = 0; i < MAX_MARCAS; i++) {
if (crescimento[i] >= 1) continue;
crescimento[i] = Math.min(1, crescimento[i] + dt / CRESCIMENTO_DA_POCA);
if (crescimento[i] >= 1) marcasCrescendo--;
pintarMarca(i);
}
}
function avancarArma(dt: number, p: Partida): void {
if (p.jogador.arma !== armaAtual) trocarArma(p.jogador.arma);
if (restoDoClarao > 0) {
restoDoClarao -= dt;
if (restoDoClarao <= 0) {
clarao.visible = false;
luz.intensity = 0;
}
}
const { x, z } = p.jogador;
const andou = Math.hypot(x - ultimoX, z - ultimoZ);
ultimoX = x;
ultimoZ = z;
const andando =
!movimentoReduzido && dt > 0 && andou / dt > 0.5 && andou < 1;
amplitude += ((andando ? 1 : 0) - amplitude) * Math.min(1, dt * 8);
if (andando) fase += dt * 9;
coice *= Math.exp(-dt * 14);
const [recuo, giroX, giroY] = COICES[armaAtual];
arma.position.set(
POSICAO_DA_ARMA.x + Math.sin(fase) * 0.012 * amplitude,
POSICAO_DA_ARMA.y - Math.abs(Math.cos(fase)) * 0.01 * amplitude,
POSICAO_DA_ARMA.z + recuo * coice,
);
arma.rotation.set(giroX * coice, GIRO_DA_ARMA + giroY * coice, 0);
}
/** O tranco é somado por cima de quem posiciona a câmera e retirado no quadro seguinte. */
function avancarTranco(dt: number): void {
if (tranco <= 0 && rolAplicado === 0 && trancoAplicado.lengthSq() === 0)
return;
camera.position.sub(trancoAplicado);
camera.rotation.z -= rolAplicado;
tranco -= dt;
if (tranco > 0) {
const forca = tranco / TRANCO;
trancoAplicado.set(
(Math.random() - 0.5) * 0.08 * forca,
(Math.random() - 0.5) * 0.06 * forca,
0,
);
rolAplicado = (Math.random() - 0.5) * 0.06 * forca;
} else {
trancoAplicado.set(0, 0, 0);
rolAplicado = 0;
}
camera.position.add(trancoAplicado);
camera.rotation.z += rolAplicado;
}
function avancarGemidos(dt: number, p: Partida): void {
proximoGemido -= dt;
if (proximoGemido > 0) return;
proximoGemido = 2 + Math.random() * 3;
const total = p.zumbis.length;
const inicio = Math.floor(Math.random() * total);
for (let n = 0; n < total; n++) {
const zumbi = p.zumbis[(inicio + n) % total];
if (zumbi.estado === "morrendo") continue;
const d = Math.hypot(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z);
if (d > ALCANCE_DO_GEMIDO) continue;
OPCOES_DO_GEMIDO.volume = 1 - d / ALCANCE_DO_GEMIDO;
som.tocar(
Math.random() < 0.5 ? "gemido-1" : "gemido-2",
OPCOES_DO_GEMIDO,
);
return;
}
}
return {
aoEvento(evento, p) {
switch (evento.tipo) {
case "tiro":
atirou(evento, p);
break;
case "acerto": {
som.tocar("acerto", { volume: 0.7, variar: true });
espirrar(evento.x, evento.y, evento.z, evento.cabeca ? 12 : 6);
const zumbi = zumbiPorId(p, evento.zumbiId);
const x = zumbi ? zumbi.x : evento.x;
const z = zumbi ? zumbi.z : evento.z;
deixarMarca(
x + (Math.random() - 0.5) * 0.4,
z + (Math.random() - 0.5) * 0.4,
0.25 + Math.random() * 0.25,
false,
);
break;
}
case "morte": {
const zumbi = zumbiPorId(p, evento.zumbiId);
if (zumbi)
deixarMarca(zumbi.x, zumbi.z, 1 + Math.random() * 0.5, true);
break;
}
case "golpe":
som.tocar("golpe");
if (!movimentoReduzido) tranco = TRANCO;
break;
case "resolvido":
som.tocar("resolvido");
break;
case "caixa":
som.tocar("caixa");
break;
}
},
avancar(dt, p) {
avancarArma(dt, p);
avancarTracantes(dt);
avancarGotas(dt);
avancarMarcas(dt);
avancarTranco(dt);
avancarGemidos(dt, p);
},
descartar() {
camera.position.sub(trancoAplicado);
camera.rotation.z -= rolAplicado;
trancoAplicado.set(0, 0, 0);
rolAplicado = 0;
arma.removeFromParent();
mundo.removeFromParent();
if (colocouCamera) camera.removeFromParent();
for (const modelo of modelos) modelo.geometry.dispose();
materialDaArma.dispose();
for (const malha of [clarao, tracantes, gotas, marcas]) {
malha.geometry.dispose();
malha.material.dispose();
}
for (const malha of [tracantes, gotas, marcas]) malha.dispose();
texturaDoClarao.dispose();
respingo.dispose();
luz.dispose();
},
};
}
+10
View File
@@ -0,0 +1,10 @@
/** Medidas dos botões de toque. O Toque posiciona os botões com elas e o Hud usa as mesmas para o feed não invadir a área deles. */
export const TOQUE_FOLGA = "16px";
export const TOQUE_ESPACO = "8px";
export const TOQUE_BOTAO = "56px";
export const TOQUE_ATIRAR = "80px";
export const TOQUE_DIREITA = `calc(${TOQUE_FOLGA} + env(safe-area-inset-right))`;
export const TOQUE_EMBAIXO = `calc(${TOQUE_FOLGA} + env(safe-area-inset-bottom))`;
/** Distância da borda de baixo até o topo do botão mais alto (a fileira de armas). */
export const TOQUE_TOPO = `calc(${TOQUE_EMBAIXO} + ${TOQUE_ATIRAR} + ${TOQUE_ESPACO} + ${TOQUE_BOTAO})`;
+19
View File
@@ -0,0 +1,19 @@
import { render, screen } from "@testing-library/react";
import JogoPage, { metadata } from "./page";
// o Jogo tem os próprios testes; aqui só importa o que a página põe em volta dele
jest.mock("./Jogo", () => ({ __esModule: true, default: () => null }));
describe("página /jogo", () => {
it("fica fora da busca", () => {
expect(metadata.robots).toEqual({ index: false, follow: false });
expect(metadata.title).toBe("Expediente dos Mortos");
});
it("sempre mostra a lista de problemas e soluções", () => {
render(<JogoPage />);
expect(
screen.getByRole("heading", { name: "Os problemas e as soluções" }),
).toBeInTheDocument();
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import Jogo from "./Jogo";
import ListaDeProblemas from "./ListaDeProblemas";
export const metadata: Metadata = {
title: "Expediente dos Mortos",
description:
"Um jogo de tiro em que cada horda de zumbis é um problema de empresa. Os problemas da sua empresa não morrem sozinhos.",
robots: { index: false, follow: false },
};
// O h1 vem do Inicio e do Relatorio, dentro do Jogo; durante a partida a camada fixa cobre a página.
export default function JogoPage() {
return (
<main className="bg-[var(--ink)]">
<Jogo />
<noscript>
<p className="px-sp-6 py-sp-8 font-display text-[17px] font-medium leading-[1.55] text-[#f4f2ed] lg:px-sp-12">
O jogo precisa de JavaScript. Os problemas e as soluções estão abaixo.
</p>
</noscript>
<ListaDeProblemas />
</main>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { criarSom } from "./som";
class GanhoFalso {
gain = { value: 1 };
connect = jest.fn();
disconnect = jest.fn();
}
class FonteFalsa {
buffer: unknown = null;
playbackRate = { value: 1 };
onended: (() => void) | null = null;
connect = jest.fn();
start = jest.fn();
stop = jest.fn();
}
class ContextoFalso {
static ultimo: ContextoFalso | null = null;
destination = {};
ganhos: GanhoFalso[] = [];
fontes: FonteFalsa[] = [];
close = jest.fn(() => Promise.resolve());
constructor() {
ContextoFalso.ultimo = this;
}
state = "running";
resume = jest.fn(() => Promise.resolve());
decodeAudioData() {
return Promise.resolve({});
}
createGain() {
const g = new GanhoFalso();
this.ganhos.push(g);
return g;
}
createBufferSource() {
const f = new FonteFalsa();
this.fontes.push(f);
return f;
}
}
const g = globalThis as unknown as { AudioContext?: unknown; fetch?: unknown };
function respostaOk() {
return Promise.resolve({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
});
}
beforeEach(() => {
ContextoFalso.ultimo = null;
g.AudioContext = ContextoFalso;
g.fetch = jest.fn(respostaOk);
});
afterEach(() => {
delete g.AudioContext;
delete g.fetch;
});
it("tocar antes de liberar não quebra nem cria contexto", () => {
const som = criarSom();
expect(() => som.tocar("pistola")).not.toThrow();
expect(ContextoFalso.ultimo).toBeNull();
expect(g.fetch).not.toHaveBeenCalled();
});
it("liberar cria o contexto e baixa os dez sons", async () => {
const som = criarSom();
await som.liberar();
expect(ContextoFalso.ultimo).not.toBeNull();
expect(g.fetch).toHaveBeenCalledTimes(10);
expect(g.fetch).toHaveBeenCalledWith("/jogo/sons/gemido-1.mp3");
som.tocar("acerto", { volume: 0.4 });
const ctx = ContextoFalso.ultimo as ContextoFalso;
expect(ctx.fontes).toHaveLength(1);
expect(ctx.fontes[0].start).toHaveBeenCalled();
expect(ctx.ganhos.at(-1)?.gain.value).toBe(0.4);
});
it("liberar de novo retoma o contexto parado, sem lançar se a retomada falhar", async () => {
const som = criarSom();
await som.liberar();
const ctx = ContextoFalso.ultimo as ContextoFalso;
ctx.resume.mockClear();
await som.liberar();
expect(ctx.resume).not.toHaveBeenCalled();
for (const parado of ["suspended", "interrupted"]) {
ctx.state = parado;
ctx.resume.mockImplementationOnce(() => Promise.reject(new Error("não")));
await expect(som.liberar()).resolves.toBeUndefined();
}
expect(ctx.resume).toHaveBeenCalledTimes(2);
expect(ContextoFalso.ultimo).toBe(ctx);
});
it("no máximo 8 vozes: a mais antiga é cortada", async () => {
const som = criarSom();
await som.liberar();
for (let i = 0; i < 10; i++) som.tocar("automatica");
const fontes = (ContextoFalso.ultimo as ContextoFalso).fontes;
expect(fontes).toHaveLength(10);
expect(fontes.filter((f) => f.stop.mock.calls.length > 0)).toEqual([
fontes[0],
fontes[1],
]);
});
it("voz que terminou libera a vaga", async () => {
const som = criarSom();
await som.liberar();
for (let i = 0; i < 8; i++) som.tocar("pistola");
const fontes = (ContextoFalso.ultimo as ContextoFalso).fontes;
fontes[3].onended?.();
som.tocar("pistola");
expect(fontes.some((f) => f.stop.mock.calls.length > 0)).toBe(false);
});
it("variar muda a velocidade em até 6%", async () => {
const som = criarSom();
await som.liberar();
for (let i = 0; i < 20; i++) som.tocar("pistola", { variar: true });
for (const f of (ContextoFalso.ultimo as ContextoFalso).fontes) {
expect(f.playbackRate.value).toBeGreaterThanOrEqual(0.94);
expect(f.playbackRate.value).toBeLessThanOrEqual(1.06);
}
});
it("mudo zera o ganho geral, antes ou depois de liberar", async () => {
const som = criarSom();
som.definirMudo(true);
await som.liberar();
const geral = (ContextoFalso.ultimo as ContextoFalso).ganhos[0];
expect(geral.gain.value).toBe(0);
som.definirMudo(false);
expect(geral.gain.value).toBe(1);
som.definirMudo(true);
expect(geral.gain.value).toBe(0);
});
it("falha de download não lança erro e o som que faltou não toca", async () => {
const erro = jest.spyOn(console, "error").mockImplementation(() => {});
g.fetch = jest.fn((url: string) => {
if (url.includes("pistola")) return Promise.reject(new Error("rede"));
if (url.includes("faca")) return Promise.resolve({ ok: false });
return respostaOk();
});
const som = criarSom();
await expect(som.liberar()).resolves.toBeUndefined();
som.tocar("pistola");
som.tocar("faca");
som.tocar("golpe");
expect((ContextoFalso.ultimo as ContextoFalso).fontes).toHaveLength(1);
expect(erro).not.toHaveBeenCalled();
erro.mockRestore();
});
it("sem AudioContext tudo vira no-op", async () => {
delete g.AudioContext;
const som = criarSom();
await expect(som.liberar()).resolves.toBeUndefined();
expect(() => {
som.tocar("drone");
som.definirMudo(true);
som.descartar();
}).not.toThrow();
expect(g.fetch).not.toHaveBeenCalled();
});
it("descartar fecha o contexto e cala o resto", async () => {
const som = criarSom();
await som.liberar();
som.tocar("caixa");
som.descartar();
const ctx = ContextoFalso.ultimo as ContextoFalso;
expect(ctx.close).toHaveBeenCalled();
expect(ctx.fontes[0].stop).toHaveBeenCalled();
som.tocar("caixa");
expect(ctx.fontes).toHaveLength(1);
});
+127
View File
@@ -0,0 +1,127 @@
/** Sons do jogo com Web Audio. Sem `AudioContext` no navegador, tudo vira no-op. */
export type NomeDoSom =
| "pistola"
| "automatica"
| "faca"
| "acerto"
| "gemido-1"
| "gemido-2"
| "golpe"
| "drone"
| "resolvido"
| "caixa";
export type Som = {
/** Cria o contexto e baixa os sons; depois, retoma o contexto parado. Chamar a cada toque ou clique que começa ou volta ao jogo. */
liberar(): Promise<void>;
tocar(nome: NomeDoSom, opcoes?: { volume?: number; variar?: boolean }): void;
definirMudo(mudo: boolean): void;
descartar(): void;
};
const NOMES: readonly NomeDoSom[] = [
"pistola",
"automatica",
"faca",
"acerto",
"gemido-1",
"gemido-2",
"golpe",
"drone",
"resolvido",
"caixa",
];
const MAXIMO_DE_VOZES = 8;
/** `variar` muda a velocidade em até 6% para cima ou para baixo. */
const VARIACAO = 0.06;
type Voz = { fonte: AudioBufferSourceNode; ganho: GainNode };
export function criarSom(): Som {
let contexto: AudioContext | null = null;
let geral: GainNode | null = null;
let carga: Promise<void> | null = null;
let mudo = false;
let descartado = false;
const buffers = new Map<NomeDoSom, AudioBuffer>();
const vozes: Voz[] = [];
async function baixar(ctx: AudioContext, nome: NomeDoSom): Promise<void> {
try {
const resposta = await fetch(`/jogo/sons/${nome}.mp3`);
if (!resposta.ok) return;
const buffer = await ctx.decodeAudioData(await resposta.arrayBuffer());
buffers.set(nome, buffer);
} catch {
// Som que não carregou simplesmente não toca.
}
}
function soltar(voz: Voz): void {
const i = vozes.indexOf(voz);
if (i >= 0) vozes.splice(i, 1);
voz.ganho.disconnect();
}
return {
liberar() {
const Contexto = globalThis.AudioContext;
if (descartado || typeof Contexto !== "function")
return Promise.resolve();
if (carga) {
// no iOS o contexto fica `interrupted` ou `suspended` ao sair da aba
if (contexto && contexto.state !== "running")
contexto.resume().catch(() => {});
return carga;
}
const ctx = new Contexto();
contexto = ctx;
geral = ctx.createGain();
geral.gain.value = mudo ? 0 : 1;
geral.connect(ctx.destination);
carga = Promise.all([
ctx.resume().catch(() => {}),
...NOMES.map((nome) => baixar(ctx, nome)),
]).then(() => {});
return carga;
},
tocar(nome, opcoes) {
const buffer = buffers.get(nome);
if (!contexto || !geral || !buffer) return;
if (vozes.length >= MAXIMO_DE_VOZES) {
const antiga = vozes[0];
antiga.fonte.stop();
soltar(antiga);
}
const fonte = contexto.createBufferSource();
fonte.buffer = buffer;
if (opcoes?.variar)
fonte.playbackRate.value = 1 + (Math.random() * 2 - 1) * VARIACAO;
const ganho = contexto.createGain();
ganho.gain.value = opcoes?.volume ?? 1;
fonte.connect(ganho);
ganho.connect(geral);
const voz = { fonte, ganho };
vozes.push(voz);
fonte.onended = () => soltar(voz);
fonte.start();
},
definirMudo(valor) {
mudo = valor;
if (geral) geral.gain.value = mudo ? 0 : 1;
},
descartar() {
descartado = true;
for (const voz of vozes.splice(0)) voz.fonte.stop();
buffers.clear();
contexto?.close().catch(() => {});
contexto = null;
geral = null;
},
};
}
+90
View File
@@ -0,0 +1,90 @@
import {
type AnimationAction,
AnimationClip,
AnimationMixer,
LoopOnce,
LoopRepeat,
type Object3D,
type Skeleton,
type SkinnedMesh,
} from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { clone } from "three/examples/jsm/utils/SkeletonUtils.js";
/** O zumbi baixado: a cena original (nunca vai para a tela, só é clonada) e os clipes de animação. */
export type ModeloDoZumbi = { cena: Object3D; clipes: AnimationClip[] };
export type Zumbi = {
raiz: Object3D;
tocar(clipe: string, opcoes?: { umaVez?: boolean }): void;
avancar(dt: number): void;
descartar(): void;
};
/** Duração da transição entre um clipe e o seguinte, em segundos. */
const TRANSICAO = 0.2;
let carga: Promise<ModeloDoZumbi> | null = null;
/** Baixa `public/jogo/zumbi.glb` uma vez só; se a carga falhar, a próxima chamada tenta de novo. */
export function carregarZumbi(): Promise<ModeloDoZumbi> {
carga ??= new GLTFLoader()
.loadAsync("/jogo/zumbi.glb")
.then((gltf) => ({ cena: gltf.scene, clipes: gltf.animations }))
.catch((erro) => {
carga = null;
throw erro;
});
return carga;
}
/**
* Um zumbi novo: esqueleto próprio e um `AnimationMixer` só dele; geometria, materiais e texturas são os do modelo,
* compartilhados entre todos. `SkeletonUtils.clone` dá um esqueleto a cada malha; as malhas do clone passam a usar o
* da primeira, para os ossos serem calculados e enviados uma vez por quadro. `descartar` solta só o que é do clone
* (mixer e textura dos ossos); quem descarta a geometria e os materiais é a cena.
*/
export function criarZumbi(modelo: ModeloDoZumbi): Zumbi {
const raiz = clone(modelo.cena);
let esqueleto: Skeleton | null = null;
raiz.traverse((objeto) => {
const malha = objeto as SkinnedMesh;
if (!malha.isSkinnedMesh) return;
// os esqueletos descartados ainda não têm textura: ela só nasce no primeiro desenho
if (esqueleto) malha.bind(esqueleto, malha.bindMatrix);
else esqueleto = malha.skeleton;
});
const mixer = new AnimationMixer(raiz);
let atual: AnimationAction | null = null;
return {
raiz,
tocar(nome, opcoes) {
const clipe = AnimationClip.findByName(modelo.clipes, nome);
if (!clipe) throw new Error(`O zumbi não tem o clipe "${nome}"`);
const acao = mixer.clipAction(clipe);
const umaVez = opcoes?.umaVez ?? false;
if (acao === atual && !umaVez) return;
// "morrer" é o último clipe de um zumbi: o próximo é de um clone que voltou da reserva, e começa do zero
if (atual?.loop === LoopOnce) {
mixer.stopAllAction();
mixer.setTime(0);
atual = null;
}
acao.reset();
acao.setLoop(umaVez ? LoopOnce : LoopRepeat, Infinity);
acao.clampWhenFinished = umaVez;
acao.play();
if (atual && atual !== acao) atual.crossFadeTo(acao, TRANSICAO, false);
atual = acao;
},
avancar(dt) {
mixer.update(dt);
},
descartar() {
mixer.stopAllAction();
mixer.uncacheRoot(raiz);
esqueleto?.dispose();
raiz.removeFromParent();
},
};
}
+4
View File
@@ -31,6 +31,10 @@ describe("sitemap", () => {
expect(urls.some((url) => /not-found|nada/.test(url))).toBe(false);
});
it("never lists the game (/jogo) or its benchmark (/jogo/bancada)", () => {
expect(urls.some((url) => url.startsWith(`${siteUrl}/jogo`))).toBe(false);
});
it("gives every route the modification date of its own content, equal on every build", () => {
const day = (value: Date | string | undefined) =>
new Date(value ?? "").toISOString().slice(0, 10);
+115
View File
@@ -0,0 +1,115 @@
import {
type Browser,
chromium,
expect,
type Page,
test,
} from "@playwright/test";
import type { ResultadoDaBancada } from "../app/jogo/Bancada";
import { DESKTOP } from "./helpers";
/** Abre a bancada, espera `window.__bancada` aparecer e o devolve. */
async function medir(page: Page, busca: string, espera: number) {
const resposta = await page.goto(`/jogo/bancada?${busca}`);
expect(resposta?.status()).toBe(200);
await page.waitForFunction(() => window.__bancada !== undefined, null, {
timeout: espera,
});
const resultado = (await page.evaluate(
() => window.__bancada,
)) as ResultadoDaBancada;
console.log(`bancada ${busca}: ${JSON.stringify(resultado)}`);
return resultado;
}
test("a bancada abre sem indexação, mede e publica o resultado sem erro no console", async ({
page,
}) => {
const erros: string[] = [];
page.on("console", (mensagem) => {
if (mensagem.type() === "error") erros.push(mensagem.text());
});
page.on("pageerror", (erro) => erros.push(erro.message));
const resultado = await medir(page, "zumbis=4&seg=2", 60_000);
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
"content",
"noindex, nofollow",
);
if ("erro" in resultado) expect(resultado.erro).toBe("sem-webgl");
else {
expect(resultado.zumbis).toBe(4);
expect(resultado.quadros).toBeGreaterThan(0);
expect(resultado.p95).toBeGreaterThan(0);
expect(typeof resultado.trabalhoP95).toBe("number");
}
expect(erros).toEqual([]);
});
test.describe("com GPU (BANCADA_GPU=1)", () => {
test.skip(!process.env.BANCADA_GPU, "precisa de GPU: BANCADA_GPU=1");
// O Playwright recusa `test.use({ launchOptions })` dentro de um describe (exigiria outro worker): o navegador com
// os argumentos de GPU abre aqui, só para este bloco, com o mesmo `PW_CHROMIUM` da configuração.
let navegador: Browser;
test.beforeAll(async () => {
navegador = await chromium.launch({
executablePath: process.env.PW_CHROMIUM || undefined,
args: [
"--enable-gpu",
"--ignore-gpu-blocklist",
"--use-angle=vulkan",
"--enable-features=Vulkan",
],
});
});
test.afterAll(() => navegador?.close());
test("16 zumbis em 1440×900: p95 até 16,7 ms", async ({ baseURL }) => {
const contexto = await navegador.newContext({ viewport: DESKTOP, baseURL });
const resultado = await medir(
await contexto.newPage(),
"zumbis=16&seg=20",
90_000,
);
if ("erro" in resultado) throw new Error("sem WebGL com GPU");
expect(resultado.p95).toBeLessThanOrEqual(16.7);
});
test("16 zumbis com a CPU 4× mais lenta: p95 até 33 ms", async ({
baseURL,
}) => {
const contexto = await navegador.newContext({ viewport: DESKTOP, baseURL });
const page = await contexto.newPage();
const cdp = await contexto.newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
const resultado = await medir(page, "zumbis=16&seg=20", 110_000);
if ("erro" in resultado) throw new Error("sem WebGL com GPU");
expect(resultado.p95).toBeLessThanOrEqual(33);
});
test("memória (CA9): 3ª medição com as mesmas geometrias e texturas da 1ª", async ({
baseURL,
}) => {
const contexto = await navegador.newContext({ viewport: DESKTOP, baseURL });
const page = await contexto.newPage();
const primeira = await medir(page, "zumbis=16&seg=3", 90_000);
if ("erro" in primeira) throw new Error("sem WebGL com GPU");
let ultima: ResultadoDaBancada = primeira;
for (let i = 0; i < 2; i += 1) {
const botao = page.getByRole("button", { name: "Medir de novo" });
await botao.click();
await botao.waitFor({ state: "detached", timeout: 30_000 });
await page.waitForFunction(() => window.__bancada !== undefined, null, {
timeout: 90_000,
});
ultima = (await page.evaluate(
() => window.__bancada,
)) as ResultadoDaBancada;
console.log(`bancada medição ${i + 2}: ${JSON.stringify(ultima)}`);
}
if ("erro" in ultima) throw new Error("sem WebGL com GPU");
expect(ultima.geometrias).toBe(primeira.geometrias);
expect(ultima.texturas).toBe(primeira.texturas);
});
});
+579
View File
@@ -0,0 +1,579 @@
import AxeBuilder from "@axe-core/playwright";
import {
type Browser,
type BrowserContext,
type BrowserContextOptions,
type CDPSession,
chromium,
expect,
type Locator,
type Page,
test,
} from "@playwright/test";
import { PROBLEMAS, problemaPorId } from "../lib/jogo/problemas";
import { DESKTOP, isDevServer, saveConsent } from "./helpers";
/**
* Ponta a ponta do Expediente dos Mortos (/jogo): CA1, CA4, CA8, CA9, CA11 e CA13 da spec
* specs/2026-11-10-jogo-zumbis-etapa-1.md.
*
* Os casos que desenham 3D abrem um Chromium próprio com os argumentos de GPU da bancada. Sem WebGL (CI sem GPU)
* eles conferem só o aviso "Este navegador não desenha 3D." e anotam que rodaram sem 3D.
* Falha num desses casos deixa a captura `falha-N.png` na pasta do teste em test-results/.
*/
const GPU = [
"--enable-gpu",
"--ignore-gpu-blocklist",
"--use-angle=vulkan",
"--enable-features=Vulkan",
];
// CA9: `gc()` na página e `performance.memory` sem arredondamento
const MEMORIA = ["--js-flags=--expose-gc", "--enable-precise-memory-info"];
const SEM_3D = "Este navegador não desenha 3D.";
// mesma exceção de e2e/routes.spec.ts: aviso que só o React de desenvolvimento emite
const AVISO_DO_DEV = /Each child in a list should have a unique "key" prop/;
const FIM = /^(Problemas resolvidos|Os problemas venceram desta vez)$/;
let navegador: Browser;
const contextos: BrowserContext[] = [];
test.beforeAll(async () => {
navegador = await chromium.launch({
executablePath: process.env.PW_CHROMIUM || undefined,
args: [...GPU, ...MEMORIA],
});
});
test.afterAll(async () => {
await navegador?.close();
});
test.afterEach(async () => {
const info = test.info();
const falhou = info.status !== info.expectedStatus;
const paginas = contextos.flatMap((c) => c.pages());
for (const [i, pagina] of paginas.entries())
if (falhou)
await pagina
.screenshot({ path: info.outputPath(`falha-${i}.png`) })
.catch(() => {});
for (const contexto of contextos.splice(0)) await contexto.close();
});
/** Página no Chromium com GPU, com o consentimento de cookies já salvo para a barra não cobrir o jogo. */
async function abrir(
opcoes: BrowserContextOptions,
baseURL: string | undefined,
): Promise<Page> {
const contexto = await navegador.newContext({ baseURL, ...opcoes });
contextos.push(contexto);
const page = await contexto.newPage();
await saveConsent(page);
return page;
}
/** Sem WebGL: confere o aviso, anota e devolve true para o teste terminar ali. */
async function sem3d(page: Page): Promise<boolean> {
const tem = await page.evaluate(() => {
const contexto = document.createElement("canvas").getContext("webgl2");
contexto?.getExtension("WEBGL_lose_context")?.loseContext();
return contexto !== null;
});
if (tem) return false;
await expect(page.getByText(SEM_3D)).toBeVisible();
test.info().annotations.push({
type: "sem 3D",
description: "WebGL indisponível: conferido só o aviso da página",
});
return true;
}
function vigiarConsole(page: Page, dev: boolean): string[] {
const erros: string[] = [];
page.on("console", (mensagem) => {
if (mensagem.type() !== "error") return;
if (dev && AVISO_DO_DEV.test(mensagem.text())) return;
erros.push(mensagem.text());
});
page.on("pageerror", (erro) => erros.push(erro.message));
return erros;
}
const estado = (page: Page) => page.evaluate(() => window.__jogo);
async function comecarPartida(page: Page, nome: string) {
await page
.locator("label", {
has: page.getByRole("radio", { name: nome, exact: true }),
})
.click();
await page
.getByRole("button", { name: /^(Começar|Jogar mesmo assim)$/ })
.click();
await page.waitForFunction(() => window.__jogo !== undefined, null, {
timeout: 90_000,
});
}
async function dentroDaTela(
alvo: Locator,
tela: { width: number; height: number },
) {
const caixa = await alvo.boundingBox();
expect(caixa, "elemento sem caixa").not.toBeNull();
if (!caixa) return;
const fora = `${JSON.stringify(caixa)} fora de ${tela.width}x${tela.height}`;
expect(caixa.x, fora).toBeGreaterThanOrEqual(0);
expect(caixa.y, fora).toBeGreaterThanOrEqual(0);
expect(caixa.x + caixa.width, fora).toBeLessThanOrEqual(tela.width + 0.5);
expect(caixa.y + caixa.height, fora).toBeLessThanOrEqual(tela.height + 0.5);
}
type Dedo = { id: number; x: number; y: number };
const tocar = (
cdp: CDPSession,
type: "touchStart" | "touchMove" | "touchEnd",
touchPoints: Dedo[],
) => cdp.send("Input.dispatchTouchEvent", { type, touchPoints });
/** O que passou pela tela durante a partida: cartões "Resolvido:" e linhas do feed. Roda dentro da página. */
type Vistos = { resolvidos: string[]; feed: string[] };
declare global {
interface Window {
__vistos?: Vistos;
}
}
function anotarTextos() {
const vistos: Vistos = { resolvidos: [], feed: [] };
window.__vistos = vistos;
const olhar = () => {
for (const p of document.querySelectorAll("p")) {
const texto = p.textContent ?? "";
const nome = texto.startsWith("Resolvido: ") ? texto.slice(11) : "";
if (nome && !vistos.resolvidos.includes(nome))
vistos.resolvidos.push(nome);
}
for (const li of document.querySelectorAll(
'[aria-label="Últimas eliminações"] li',
)) {
const texto = li.textContent ?? "";
if (!vistos.feed.includes(texto)) vistos.feed.push(texto);
}
};
new MutationObserver(olhar).observe(document, {
childList: true,
subtree: true,
characterData: true,
});
}
async function axeSerio(page: Page) {
const { violations } = await new AxeBuilder({ page }).analyze();
return violations
.filter(({ impact }) => impact === "serious" || impact === "critical")
.map(
({ id, impact, nodes }) =>
`${id} (${impact}): ${nodes.length} elemento(s), por exemplo ${nodes[0]?.target.join(" ")}`,
);
}
test("CA1: o piloto joga a partida inteira e o relatório liga cada problema ao serviço", async ({
baseURL,
request,
}) => {
test.setTimeout(300_000);
const dev = await isDevServer(request);
const page = await abrir({ viewport: { width: 960, height: 540 } }, baseURL);
const erros = vigiarConsole(page, dev);
await page.addInitScript(anotarTextos);
await page.goto(
"/jogo?piloto=1&semente=1&problema=site-lento&dificuldade=facil&vel=8",
);
if (await sem3d(page)) return;
const titulo = page.getByRole("heading", { level: 1, name: FIM });
await expect(titulo).toBeVisible({ timeout: 240_000 });
const vistos = await page.evaluate(() => window.__vistos);
if (!vistos) throw new Error("o observador de textos não rodou");
console.log(`CA1 vistos: ${JSON.stringify(vistos)}`);
expect(vistos.resolvidos.length, "cartão Resolvido:").toBeGreaterThan(0);
expect(
vistos.feed.filter((linha) => linha.includes("eliminou")).length,
"linha do feed com eliminou",
).toBeGreaterThan(0);
await expect(page.getByText(/^Você \d+ · Drones \d+$/)).toBeVisible();
// o nome acessível junta o texto e o trecho só para leitor de tela, com ou sem espaço antes dos dois-pontos
const links = page.getByRole("link", { name: /^Ver como eu resolvo isso/ });
const nomes: string[] = [];
for (const link of await links.all()) {
const nome = (await link.textContent())?.split(": ")[1] ?? "";
const problema = PROBLEMAS.find((p) => p.nome === nome);
expect(problema, `problema do link "${nome}"`).toBeDefined();
await expect(link).toHaveAttribute("href", problema?.destino ?? "");
nomes.push(nome);
}
console.log(`CA1 relatório: ${await titulo.textContent()} · ${nomes}`);
expect(new Set(nomes).size, "um link por problema").toBe(nomes.length);
for (const nome of vistos.resolvidos) expect(nomes).toContain(nome);
const chefao = problemaPorId("site-lento");
if ((await titulo.textContent()) === "Problemas resolvidos")
expect(nomes).toContain(chefao?.nome);
await expect(
// o rodapé do site tem outro "Chamar no WhatsApp", com a mensagem genérica
page
.getByRole("main")
.getByRole("link", { name: "Chamar no WhatsApp", exact: true }),
).toHaveAttribute(
"href",
new RegExp(encodeURIComponent(chefao?.nome ?? "?")),
);
expect(erros).toEqual([]);
});
for (const tela of [
{ width: 915, height: 412 },
{ width: 640, height: 360 },
]) {
test(`CA4: toque em ${tela.width}x${tela.height}: controles na tela, andar, mirar e acertar`, async ({
baseURL,
}) => {
test.setTimeout(200_000);
const page = await abrir(
{ viewport: tela, hasTouch: true, isMobile: true },
baseURL,
);
await page.goto("/jogo");
if (await sem3d(page)) return;
await comecarPartida(page, "Site lento");
const atirar = page.getByRole("button", { name: "Atirar", exact: true });
await expect(atirar).toBeVisible({ timeout: 30_000 });
const cdp = await page.context().newCDPSession(page);
const W = tela.width;
const H = tela.height;
// direcional na metade esquerda; empurrado para a frente, anda
const mover: Dedo = {
id: 1,
x: Math.round(W * 0.2),
y: Math.round(H * 0.6),
};
const parado = await estado(page);
await tocar(cdp, "touchStart", [mover]);
await expect(page.getByTestId("direcional")).toBeVisible();
await dentroDaTela(page.getByTestId("direcional"), tela);
await tocar(cdp, "touchMove", [{ ...mover, y: mover.y - 50 }]);
await page.waitForTimeout(1500);
const andou = await estado(page);
await tocar(cdp, "touchEnd", []);
expect(
Math.hypot(
(andou?.x ?? 0) - (parado?.x ?? 0),
(andou?.z ?? 0) - (parado?.z ?? 0),
),
`de ${JSON.stringify(parado)} para ${JSON.stringify(andou)}`,
).toBeGreaterThan(0.01);
await dentroDaTela(atirar, tela);
for (const arma of [/^Pistola$/, /^Automática/, /^Faca$/])
await dentroDaTela(page.getByRole("button", { name: arma }), tela);
// arrastar na metade direita gira
const mirar: Dedo = {
id: 2,
x: Math.round(W * 0.6),
y: Math.round(H * 0.3),
};
const rumo = (await estado(page))?.rumo;
await tocar(cdp, "touchStart", [mirar]);
for (let dx = 8; dx <= 80; dx += 8)
await tocar(cdp, "touchMove", [{ ...mirar, x: mirar.x + dx }]);
await tocar(cdp, "touchEnd", []);
await page.waitForTimeout(300);
expect((await estado(page))?.rumo, "rumo").not.toBe(rumo);
// segura Atirar e varre a mira em voltas
const caixa = await atirar.boundingBox();
if (!caixa) throw new Error("Atirar sem caixa");
const tiro: Dedo = {
id: 3,
x: Math.round(caixa.x + caixa.width / 2),
y: Math.round(caixa.y + caixa.height / 2),
};
const limite = Date.now() + 90_000;
let acertos = 0;
while (acertos < 1 && Date.now() < limite) {
const varrer: Dedo = { id: 4, x: Math.round(W * 0.55), y: mirar.y };
await tocar(cdp, "touchStart", [tiro]);
await tocar(cdp, "touchStart", [tiro, varrer]);
for (let dx = 6; dx <= W * 0.35 && acertos < 1; dx += 6) {
await tocar(cdp, "touchMove", [tiro, { ...varrer, x: varrer.x + dx }]);
await page.waitForTimeout(40);
acertos = (await estado(page))?.placar.jogador ?? 0;
}
await tocar(cdp, "touchEnd", []);
}
const fim = await estado(page);
expect(
fim?.placar.jogador,
`estado: ${JSON.stringify(fim)}`,
).toBeGreaterThanOrEqual(1);
});
}
test("CA4: em retrato, pede para virar o aparelho", async ({ baseURL }) => {
test.setTimeout(120_000);
const page = await abrir(
{ viewport: { width: 412, height: 915 }, hasTouch: true, isMobile: true },
baseURL,
);
await page.goto("/jogo");
if (await sem3d(page)) return;
await comecarPartida(page, "Site lento");
await expect(page.getByText("Vire o aparelho para jogar")).toBeVisible();
});
test("CA8: a home, rolada até o fim, não pede nada do jogo", async ({
browser,
baseURL,
request,
}) => {
test.setTimeout(180_000);
/** Caminhos dos scripts que a página pede até a rede parar. */
async function scripts(caminho: string) {
const contexto = await browser.newContext({ baseURL });
contextos.push(contexto);
const page = await contexto.newPage();
const pedidos = new Set<string>();
page.on("request", (pedido) => {
if (pedido.resourceType() === "script")
pedidos.add(new URL(pedido.url()).pathname);
});
await page.goto(caminho);
await page.waitForLoadState("networkidle");
return pedidos;
}
// Blocos da cena: dos scripts que a partida automática pede a mais que a tela inicial parada, os que trazem o
// código da cena (ela publica `window.__jogo`). O three em si fica de fora: o fundo 3D da home
// (HeroField → BlocosScene) já o carrega sob demanda, e é o mesmo arquivo.
const parada = await scripts("/jogo");
const novos = [
...(await scripts("/jogo?piloto=1&problema=site-lento")),
].filter((caminho) => !parada.has(caminho));
const blocos: string[] = [];
for (const caminho of novos)
if ((await (await request.get(caminho)).text()).includes("__jogo"))
blocos.push(caminho);
console.log(`CA8 pedidos da partida: ${novos.join(" ")}`);
console.log(`CA8 blocos da cena: ${blocos.join(" ")}`);
expect(blocos.length, "blocos da cena achados").toBeGreaterThan(0);
const contexto = await browser.newContext({ baseURL });
contextos.push(contexto);
const page = await contexto.newPage();
const pedidos: string[] = [];
page.on("request", (pedido) => {
pedidos.push(pedido.url());
});
await page.goto("/");
for (let i = 0; i < 100; i += 1) {
const noFim = await page.evaluate(() => {
window.scrollBy(0, window.innerHeight * 0.8);
return (
window.scrollY + window.innerHeight >=
document.documentElement.scrollHeight - 2
);
});
await page.waitForTimeout(150);
if (noFim) break;
}
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1500);
const doJogo = pedidos.filter(
(url) => url.includes("/jogo/") || blocos.includes(new URL(url).pathname),
);
console.log(
`CA8 da partida também pedidos pela home: ${novos.filter((caminho) => pedidos.some((url) => new URL(url).pathname === caminho))}`,
);
expect(doJogo).toEqual([]);
});
test("CA9: três partidas seguidas sem acumular geometria, textura nem memória", async ({
baseURL,
}) => {
test.setTimeout(900_000);
const page = await abrir({ viewport: DESKTOP }, baseURL);
// semente fixa na 1ª partida; "Jogar de novo" sorteia outra
await page.goto("/jogo?piloto=1&semente=1&problema=site-lento&vel=16");
if (await sem3d(page)) return;
const cdp = await page.context().newCDPSession(page);
const jogarDeNovo = page.getByRole("button", { name: "Jogar de novo" });
const medidas: {
geometrias?: number;
texturas?: number;
heap: number | null;
sobras?: { geometrias: number; texturas: number };
}[] = [];
for (let i = 0; i < 3; i += 1) {
if (i > 0) await jogarDeNovo.click();
const pronto = await page.waitForFunction(
() => {
const j = window.__jogo;
return j?.horda === 1 && j.fase === "horda"
? { geometrias: j.geometrias, texturas: j.texturas }
: null;
},
null,
{ timeout: 180_000, polling: 50 },
);
const contagem = await pronto.jsonValue();
// coleta completa pelo protocolo do Chromium antes de medir
await cdp.send("HeapProfiler.collectGarbage");
const heap = await page.evaluate(() => {
// `performance.memory` só existe no Chromium (preciso com as flags de MEMORIA); os tipos do DOM não o trazem
const desempenho = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return desempenho.memory?.usedJSHeapSize ?? null;
});
// no fim da partida a cena é descartada e publica o que o renderer ainda contava
await expect(jogarDeNovo).toBeVisible({ timeout: 300_000 });
const sobras = await page.evaluate(() => window.__jogo?.sobras);
medidas.push({ ...contagem, heap, sobras });
console.log(`CA9 partida ${i + 1}: ${JSON.stringify(medidas[i])}`);
}
const [primeira, , terceira] = medidas;
expect(terceira.geometrias, "geometrias").toBe(primeira.geometrias);
expect(terceira.texturas, "texturas").toBe(primeira.texturas);
for (const [i, { sobras }] of medidas.entries())
expect(sobras, `sobras da partida ${i + 1}`).toEqual({
geometrias: 0,
texturas: 0,
});
if (primeira.heap !== null && terceira.heap !== null)
expect(terceira.heap, "heap JS").toBeLessThanOrEqual(primeira.heap * 1.1);
});
test.describe("CA11: acesso", () => {
test("sem JavaScript, mostra os 27 problemas com link", async ({
browser,
baseURL,
}) => {
const contexto = await browser.newContext({
baseURL,
javaScriptEnabled: false,
});
contextos.push(contexto);
const page = await contexto.newPage();
await page.goto("/jogo");
const lista = page.locator('section[aria-labelledby="lista-de-problemas"]');
await expect(lista.getByRole("link")).toHaveCount(27);
for (const problema of PROBLEMAS) {
const item = lista
.getByRole("listitem")
.filter({ hasText: problema.nome });
await expect(item.getByRole("link")).toHaveAttribute(
"href",
problema.destino,
);
}
});
test("com movimento reduzido, oferece Jogar mesmo assim, e o aviso de sangue vem antes", async ({
baseURL,
}) => {
const page = await abrir(
{ viewport: DESKTOP, reducedMotion: "reduce" },
baseURL,
);
await page.goto("/jogo");
await expect(
page.getByText("Tem sangue e sustos. É um jogo de tiro."),
).toBeVisible();
if (await sem3d(page)) return;
await expect(
page.getByRole("button", { name: "Jogar mesmo assim" }),
).toBeVisible();
});
test("no computador, o mudo alterna e perder a trava do mouse pausa", async ({
baseURL,
}) => {
test.setTimeout(120_000);
const page = await abrir({ viewport: DESKTOP }, baseURL);
await page.goto("/jogo");
if (await sem3d(page)) return;
await comecarPartida(page, "Site lento");
const desligar = page.getByRole("button", { name: "Desligar o som" });
const ligar = page.getByRole("button", { name: "Ligar o som" });
await desligar.click();
await expect(ligar).toBeVisible();
await ligar.click();
await expect(desligar).toBeVisible();
const pausado = page.getByRole("heading", { name: "Pausado" });
await page.mouse.click(DESKTOP.width / 2, DESKTOP.height / 2);
const travou = await page
.waitForFunction(() => document.pointerLockElement !== null, null, {
timeout: 5_000,
})
.then(
() => true,
() => false,
);
test.info().annotations.push({
type: "trava do mouse",
description: travou ? "obtida ao clicar" : "não obtida no clique",
});
console.log(`CA11 trava do mouse obtida: ${travou}`);
await page.keyboard.press("Escape");
const pausouComEsc = await pausado.waitFor({ timeout: 3_000 }).then(
() => true,
() => false,
);
console.log(`CA11 Esc pausou: ${pausouComEsc}`);
if (!pausouComEsc) {
test.info().annotations.push({
type: "pausa",
description: "Esc não pausou; conferido saindo da trava do mouse",
});
await page.evaluate(() => document.exitPointerLock());
}
await expect(pausado).toBeVisible();
});
test("axe sem violação serious ou critical na tela inicial", async ({
baseURL,
}) => {
const page = await abrir({ viewport: DESKTOP }, baseURL);
await page.goto("/jogo", { waitUntil: "load" });
await page.waitForTimeout(800);
expect(await axeSerio(page)).toEqual([]);
});
test("axe sem violação serious ou critical no relatório", async ({
baseURL,
}) => {
test.setTimeout(300_000);
const page = await abrir({ viewport: DESKTOP }, baseURL);
await page.goto("/jogo?piloto=1&semente=1&problema=site-lento&vel=16");
if (await sem3d(page)) return;
await expect(
page.getByRole("heading", { level: 1, name: FIM }),
).toBeVisible({ timeout: 240_000 });
await page.waitForTimeout(800);
expect(await axeSerio(page)).toEqual([]);
});
});
test("CA13: /jogo e /jogo/bancada trazem noindex", async ({ page }) => {
for (const caminho of ["/jogo", "/jogo/bancada"]) {
await page.goto(caminho);
await expect(page.locator('meta[name="robots"]'), caminho).toHaveAttribute(
"content",
/noindex/,
);
}
});
+27
View File
@@ -0,0 +1,27 @@
import { alavanca } from "./alavanca";
describe("alavanca", () => {
it("fica parada dentro da zona morta", () => {
expect(alavanca(4, -4, 56)).toEqual([0, 0]);
expect(alavanca(0, 0, 56)).toEqual([0, 0]);
});
it("empurrar para cima anda para a frente", () => {
const [x, y] = alavanca(0, -28, 56);
expect(x).toBeCloseTo(0);
expect(y).toBeCloseTo(0.5);
});
it("empurrar para baixo e para a direita anda para trás e para a direita", () => {
const [x, y] = alavanca(28, 28, 56);
expect(x).toBeCloseTo(0.5);
expect(y).toBeCloseTo(-0.5);
});
it("limita o comprimento a 1, mantendo a direção", () => {
const [x, y] = alavanca(-300, -300, 56);
expect(Math.hypot(x, y)).toBeCloseTo(1);
expect(x).toBeCloseTo(-Math.SQRT1_2);
expect(y).toBeCloseTo(Math.SQRT1_2);
});
});
+14
View File
@@ -0,0 +1,14 @@
/** Deslocamento do dedo em pixels (dy cresce para baixo) em [direita, frente], de -1 a 1. */
const ZONA_MORTA = 0.12;
export function alavanca(
dx: number,
dy: number,
raio: number,
): [number, number] {
const comprimento = Math.hypot(dx, dy) / raio;
if (comprimento < ZONA_MORTA) return [0, 0];
const escala = Math.min(1, comprimento) / comprimento / raio;
return [dx * escala, 0 - dy * escala];
}
+375
View File
@@ -0,0 +1,375 @@
import {
alvoNaMira,
atirar,
raioNoCirculo,
recolherCaixas,
resfriar,
trocarArma,
} from "./armas";
import { lerMapa } from "./mapa";
import type { ArmaId, Partida, TipoZumbi, Zumbi } from "./tipos";
import { ARMAS, JOGADOR, ZUMBIS } from "./valores";
const LARGURA = 7;
function mapaAberto() {
const linhas = ["#######", "#N...J#"];
for (let i = 0; i < 13; i++) linhas.push("#.....#");
linhas.push("#######");
return lerMapa(linhas);
}
function zumbi(
id: number,
x: number,
z: number,
tipo: TipoZumbi = "comum",
): Zumbi {
return {
id,
tipo,
problemaId: "site-lento",
x,
z,
rumo: 0,
vida: ZUMBIS[tipo].vida,
estado: "andando",
tempo: 0,
};
}
/** Jogador em (3.5, 2.5) olhando para +z, com a pistola. */
function partida(arma: ArmaId = 0): Partida {
return {
passo: 0,
rng: 1,
dificuldade: "normal",
problemaId: "site-lento",
hordas: [],
horda: 0,
fase: "horda",
tempoFase: 0,
tetoAVista: 16,
mapa: mapaAberto(),
jogador: {
x: 3.5,
z: 2.5,
rumo: 0,
inclinacao: 0,
vida: JOGADOR.vida,
arma,
municao: 10,
recarga: 0,
},
zumbis: [],
fila: [],
drones: [],
caixas: [],
feed: [],
resolvidos: [],
placar: { jogador: 0, drones: 0 },
eventos: [],
proximoId: 100,
};
}
describe("alvoNaMira", () => {
it("é nulo com a faca", () => {
const p = partida(2);
p.zumbis = [zumbi(1, 3.5, 3.5)];
expect(alvoNaMira(p)).toBeNull();
});
it("é nulo sem zumbi na linha", () => {
const p = partida();
p.zumbis = [zumbi(1, 1.5, 5.5)];
expect(alvoNaMira(p)).toBeNull();
});
it("devolve o corpo, com a distância e a altura", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
p.jogador.inclinacao = Math.atan(-0.2);
const alvo = alvoNaMira(p);
expect(alvo?.zumbi.id).toBe(1);
expect(alvo?.cabeca).toBe(false);
expect(alvo?.distancia).toBeCloseTo(2.65);
expect(alvo?.y).toBeCloseTo(1.6 - 0.2 * 2.65);
});
it("devolve a cabeça e não fere ninguém", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
expect(alvoNaMira(p)?.cabeca).toBe(true);
expect(p.zumbis[0].vida).toBe(100);
expect(p.eventos).toHaveLength(0);
});
});
describe("raioNoCirculo", () => {
it("devolve a distância até a primeira interseção", () => {
expect(raioNoCirculo(0, 0, 0, 1, 0, 5, 1)).toBeCloseTo(4);
});
it("devolve null quando o raio passa ao lado", () => {
expect(raioNoCirculo(0, 0, 0, 1, 3, 5, 1)).toBeNull();
});
it("devolve null quando o círculo está atrás", () => {
expect(raioNoCirculo(0, 0, 0, 1, 0, -5, 1)).toBeNull();
});
it("devolve 0 com a origem dentro do círculo", () => {
expect(raioNoCirculo(0, 0.2, 0, 1, 0, 0, 1)).toBe(0);
});
});
describe("trocarArma", () => {
it("troca e põe a recarga em 10", () => {
const p = partida(0);
trocarArma(p, 1);
expect(p.jogador.arma).toBe(1);
expect(p.jogador.recarga).toBe(10);
});
it("não faz nada para a mesma arma", () => {
const p = partida(0);
trocarArma(p, 0);
expect(p.jogador.recarga).toBe(0);
});
});
describe("resfriar", () => {
it("desconta 1 da recarga", () => {
const p = partida();
p.jogador.recarga = 3;
resfriar(p);
expect(p.jogador.recarga).toBe(2);
});
it("para em 0", () => {
const p = partida();
resfriar(p);
expect(p.jogador.recarga).toBe(0);
});
});
describe("recolherCaixas", () => {
it("recolhe a caixa perto e deixa a longe", () => {
const p = partida();
p.caixas = [
{ id: 1, x: 3.5, z: 3.0 },
{ id: 2, x: 3.5, z: 8.5 },
];
recolherCaixas(p);
expect(p.caixas.map((c) => c.id)).toEqual([2]);
expect(p.jogador.municao).toBe(40);
expect(p.eventos).toEqual([{ tipo: "caixa", balas: 30 }]);
});
});
describe("atirar: regras gerais", () => {
it("não faz nada com recarga pendente", () => {
const p = partida();
p.jogador.recarga = 1;
atirar(p);
expect(p.eventos).toEqual([]);
expect(p.jogador.recarga).toBe(1);
});
it("automática sem bala não atira e não gasta recarga", () => {
const p = partida(1);
p.jogador.municao = 0;
atirar(p);
expect(p.eventos).toEqual([]);
expect(p.jogador.recarga).toBe(0);
});
it("automática gasta uma bala e arma a recarga", () => {
const p = partida(1);
atirar(p);
expect(p.jogador.municao).toBe(9);
expect(p.jogador.recarga).toBe(ARMAS[1].intervalo);
});
it("pistola nunca gasta bala, nem com a munição zerada", () => {
const p = partida(0);
p.jogador.municao = 0;
atirar(p);
expect(p.jogador.municao).toBe(0);
expect(p.jogador.recarga).toBe(ARMAS[0].intervalo);
expect(p.eventos).toHaveLength(1);
});
});
describe("atirar: tiro por raio", () => {
it("sem zumbi, o tiro vai até a parede", () => {
const p = partida();
atirar(p);
expect(p.eventos).toHaveLength(1);
const e = p.eventos[0];
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(12.5);
});
it("acerta o corpo com o dano da arma", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
p.jogador.inclinacao = Math.atan(-0.2);
atirar(p);
expect(p.zumbis[0].vida).toBe(100 - ARMAS[0].dano);
const [tiro, acerto] = p.eventos;
expect(tiro).toMatchObject({ tipo: "tiro", autor: "jogador", arma: 0 });
expect(tiro.tipo === "tiro" && tiro.distancia).toBeCloseTo(2.65);
expect(acerto).toMatchObject({
tipo: "acerto",
zumbiId: 1,
cabeca: false,
});
expect(acerto.tipo === "acerto" && acerto.y).toBeCloseTo(1.6 - 0.2 * 2.65);
expect(acerto.tipo === "acerto" && acerto.x).toBeCloseTo(3.5);
expect(acerto.tipo === "acerto" && acerto.z).toBeCloseTo(5.15);
});
it("acerta a cabeça com o dano dobrado", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100 - ARMAS[0].dano * 2);
expect(p.eventos[1]).toMatchObject({ tipo: "acerto", cabeca: true });
});
it("erra por cima com a inclinação alta", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
p.jogador.inclinacao = 1;
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.eventos).toHaveLength(1);
const e = p.eventos[0];
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(12.5);
});
it("erra por baixo, no chão", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
p.jogador.inclinacao = Math.atan(-1);
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.eventos).toHaveLength(1);
});
it("o raio que passa ao lado não acerta", () => {
const p = partida();
p.zumbis = [zumbi(1, 5.5, 5.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
});
it("parede na frente do zumbi o protege", () => {
const p = partida();
p.mapa.opaco[8 * LARGURA + 3] = 1;
p.zumbis = [zumbi(1, 3.5, 10.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
const e = p.eventos[0];
expect(p.eventos).toHaveLength(1);
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(5.5);
});
it("o zumbi da frente protege o de trás", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 9.5), zumbi(2, 3.5, 5.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.zumbis[1].vida).toBe(32);
});
it("zumbi morrendo é atravessado", () => {
const p = partida();
const morto = zumbi(1, 3.5, 5.5);
morto.estado = "morrendo";
p.zumbis = [morto, zumbi(2, 3.5, 9.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.zumbis[1].vida).toBe(32);
});
it("o tiro que passa por cima do da frente pode acertar o de trás", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5, "rapido"), zumbi(2, 3.5, 9.5, "chefao")];
p.jogador.inclinacao = Math.atan(0.05);
atirar(p);
expect(p.zumbis[0].vida).toBe(50);
expect(p.zumbis[1].vida).toBeLessThan(1500);
});
it("matar registra a morte pelo ferir", () => {
const p = partida();
const fraco = zumbi(1, 3.5, 5.5);
fraco.vida = 10;
p.zumbis = [fraco];
atirar(p);
expect(fraco.estado).toBe("morrendo");
expect(p.eventos.some((e) => e.tipo === "morte")).toBe(true);
});
it("a automática usa o dano da automática", () => {
const p = partida(1);
p.zumbis = [zumbi(1, 3.5, 5.5)];
p.jogador.inclinacao = Math.atan(-0.2);
atirar(p);
expect(p.zumbis[0].vida).toBe(100 - ARMAS[1].dano);
});
});
describe("atirar: faca", () => {
it("fere o mais próximo dentro do alcance e do arco", () => {
const p = partida(2);
p.zumbis = [zumbi(1, 3.5, 3.9), zumbi(2, 3.5, 3.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.zumbis[1].vida).toBe(100 - ARMAS[2].dano);
expect(p.eventos[0]).toMatchObject({ tipo: "tiro", arma: 2 });
const acerto = p.eventos.find((e) => e.tipo === "acerto");
expect(acerto).toMatchObject({
zumbiId: 2,
cabeca: false,
y: ZUMBIS.comum.altura / 2,
});
});
it("não acerta fora do arco", () => {
const p = partida(2);
p.zumbis = [zumbi(1, 3.5, 1.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.eventos).toEqual([
{ tipo: "tiro", autor: "jogador", arma: 2, distancia: ARMAS[2].alcance },
]);
});
it("não acerta fora do alcance", () => {
const p = partida(2);
p.zumbis = [zumbi(1, 3.5, 5.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.eventos).toHaveLength(1);
});
it("ignora o zumbi morrendo", () => {
const p = partida(2);
const morto = zumbi(1, 3.5, 3.5);
morto.estado = "morrendo";
p.zumbis = [morto];
atirar(p);
expect(p.eventos).toHaveLength(1);
});
it("a distância do tiro é a do zumbi ferido", () => {
const p = partida(2);
p.zumbis = [zumbi(1, 3.5, 3.5)];
atirar(p);
const e = p.eventos[0];
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(0.65);
});
});
+206
View File
@@ -0,0 +1,206 @@
/** Armas do jogador: tiro por raio, faca, troca, recarga e coleta de caixas. Sem three, React nem DOM. */
import { raioAteParede } from "./mapa";
import type { ArmaId, Partida, Zumbi } from "./tipos";
import {
ARCO_DA_FACA,
ARMAS,
FRACAO_DA_CABECA,
JOGADOR,
MULTIPLICADOR_DA_CABECA,
MUNICAO,
ZUMBIS,
} from "./valores";
import { ferir } from "./zumbis";
const FACA: ArmaId = 2;
const RECARGA_DA_TROCA = 10;
/** Distância ao longo do raio até o círculo; `null` se não cruza ou fica atrás; 0 se a origem está dentro. */
export function raioNoCirculo(
ox: number,
oz: number,
dx: number,
dz: number,
cx: number,
cz: number,
r: number,
): number | null {
const fx = ox - cx;
const fz = oz - cz;
const fora = fx * fx + fz * fz - r * r;
if (fora <= 0) return 0;
const b = fx * dx + fz * dz;
const delta = b * b - fora;
if (delta < 0) return null;
const t = -b - Math.sqrt(delta);
return t < 0 ? null : t;
}
export function trocarArma(p: Partida, arma: ArmaId): void {
if (arma === p.jogador.arma) return;
p.jogador.arma = arma;
p.jogador.recarga = RECARGA_DA_TROCA;
}
export function resfriar(p: Partida): void {
p.jogador.recarga = Math.max(0, p.jogador.recarga - 1);
}
export function recolherCaixas(p: Partida): void {
const { x, z } = p.jogador;
const perto = (c: { x: number; z: number }) =>
Math.hypot(c.x - x, c.z - z) <= MUNICAO.raioDeColeta;
const recolhidas = p.caixas.filter(perto);
for (let i = 0; i < recolhidas.length; i++) {
p.jogador.municao += MUNICAO.porCaixa;
p.eventos.push({ tipo: "caixa", balas: MUNICAO.porCaixa });
}
p.caixas = p.caixas.filter((c) => !perto(c));
}
type Cruzamento = { zumbi: Zumbi; t: number };
/** Zumbis vivos cruzados pelo raio antes do limite, do mais perto para o mais longe. */
function cruzamentos(
p: Partida,
dx: number,
dz: number,
limite: number,
): Cruzamento[] {
const lista: Cruzamento[] = [];
for (const zumbi of p.zumbis) {
if (zumbi.estado === "morrendo") continue;
const { x, z } = p.jogador;
const t = raioNoCirculo(
x,
z,
dx,
dz,
zumbi.x,
zumbi.z,
ZUMBIS[zumbi.tipo].raio,
);
if (t !== null && t < limite) lista.push({ zumbi, t });
}
return lista.sort((a, b) => a.t - b.t);
}
/** O primeiro zumbi cuja altura contém a altura do tiro, com essa altura. */
function primeiroAtingido(
p: Partida,
lista: Cruzamento[],
): (Cruzamento & { y: number }) | null {
const subida = Math.tan(p.jogador.inclinacao);
for (const c of lista) {
const y = JOGADOR.olhos + subida * c.t;
if (y >= 0 && y <= ZUMBIS[c.zumbi.tipo].altura) return { ...c, y };
}
return null;
}
/** Distância até a parede (ou o alcance da arma) no rumo atual. */
function limiteDoRaio(p: Partida): number {
const { x, z, rumo, arma } = p.jogador;
return raioAteParede(p.mapa, x, z, rumo, ARMAS[arma].alcance);
}
/** O zumbi que um tiro de raio acertaria agora; `null` com a faca ou sem alvo. */
export function alvoNaMira(p: Partida): {
zumbi: Zumbi;
distancia: number;
cabeca: boolean;
y: number;
} | null {
const { rumo, arma } = p.jogador;
if (arma === FACA) return null;
const lista = cruzamentos(p, Math.sin(rumo), Math.cos(rumo), limiteDoRaio(p));
const alvo = primeiroAtingido(p, lista);
if (!alvo) return null;
const { zumbi, t, y } = alvo;
const cabeca = y >= FRACAO_DA_CABECA * ZUMBIS[zumbi.tipo].altura;
return { zumbi, distancia: t, cabeca, y };
}
function atirarPorRaio(p: Partida): void {
const { x, z, rumo, arma } = p.jogador;
const alvo = alvoNaMira(p);
p.eventos.push({
tipo: "tiro",
autor: "jogador",
arma,
distancia: alvo ? alvo.distancia : limiteDoRaio(p),
});
if (!alvo) return;
const { zumbi, distancia: t, cabeca, y } = alvo;
const dx = Math.sin(rumo);
const dz = Math.cos(rumo);
ferir(
p,
zumbi,
cabeca ? ARMAS[arma].dano * MULTIPLICADOR_DA_CABECA : ARMAS[arma].dano,
"jogador",
);
p.eventos.push({
tipo: "acerto",
zumbiId: zumbi.id,
cabeca,
x: x + dx * t,
y,
z: z + dz * t,
});
}
/** Ângulo, de 0 a π, entre o rumo do jogador e a direção até o ponto. */
function desvio(p: Partida, zumbi: Zumbi): number {
const d =
Math.atan2(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z) - p.jogador.rumo;
return Math.abs(Math.atan2(Math.sin(d), Math.cos(d)));
}
/** Zumbi vivo mais próximo ao alcance da faca e dentro do arco, com a folga até a borda dele. */
function alvoDaFaca(p: Partida): { zumbi: Zumbi; folga: number } | null {
let melhor: { zumbi: Zumbi; folga: number } | null = null;
for (const zumbi of p.zumbis) {
if (zumbi.estado === "morrendo") continue;
const folga =
Math.hypot(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z) -
ZUMBIS[zumbi.tipo].raio;
const vale =
folga <= ARMAS[FACA].alcance && desvio(p, zumbi) <= ARCO_DA_FACA;
if (vale && (melhor === null || folga < melhor.folga))
melhor = { zumbi, folga };
}
return melhor;
}
function golpearComFaca(p: Partida): void {
const alvo = alvoDaFaca(p);
p.eventos.push({
tipo: "tiro",
autor: "jogador",
arma: FACA,
distancia: alvo ? alvo.folga : ARMAS[FACA].alcance,
});
if (!alvo) return;
const { zumbi } = alvo;
ferir(p, zumbi, ARMAS[FACA].dano, "jogador");
p.eventos.push({
tipo: "acerto",
zumbiId: zumbi.id,
cabeca: false,
x: zumbi.x,
y: ZUMBIS[zumbi.tipo].altura / 2,
z: zumbi.z,
});
}
export function atirar(p: Partida): void {
const j = p.jogador;
const arma = ARMAS[j.arma];
if (j.recarga > 0 || (arma.gasta && j.municao === 0)) return;
j.recarga = arma.intervalo;
if (arma.gasta) j.municao -= 1;
if (j.arma === FACA) golpearComFaca(p);
else atirarPorRaio(p);
}
+160
View File
@@ -0,0 +1,160 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
const LIMITE = 10;
const PASTA = __dirname;
type Medida = { nome: string; linha: number; complexidade: number };
const OPERADORES = new Set([
ts.SyntaxKind.AmpersandAmpersandToken,
ts.SyntaxKind.BarBarToken,
ts.SyntaxKind.QuestionQuestionToken,
]);
function ehDecisao(no: ts.Node): boolean {
if (ts.isBinaryExpression(no)) return OPERADORES.has(no.operatorToken.kind);
return (
ts.isIfStatement(no) ||
ts.isConditionalExpression(no) ||
ts.isForStatement(no) ||
ts.isForInStatement(no) ||
ts.isForOfStatement(no) ||
ts.isWhileStatement(no) ||
ts.isDoStatement(no) ||
ts.isCaseClause(no) ||
ts.isCatchClause(no)
);
}
function ehFuncao(no: ts.Node): no is ts.FunctionLikeDeclaration {
return (
ts.isFunctionDeclaration(no) ||
ts.isFunctionExpression(no) ||
ts.isArrowFunction(no) ||
ts.isMethodDeclaration(no) ||
ts.isConstructorDeclaration(no) ||
ts.isGetAccessorDeclaration(no) ||
ts.isSetAccessorDeclaration(no)
);
}
function nomeDaFuncao(no: ts.FunctionLikeDeclaration): string {
if (ts.isConstructorDeclaration(no)) return "constructor";
if (no.name) return no.name.getText();
const pai = no.parent;
if (ts.isVariableDeclaration(pai) || ts.isPropertyAssignment(pai)) {
return pai.name.getText();
}
return "(anônima)";
}
/** Complexidade ciclomática de cada função do código-fonte (cada uma medida à parte). */
export function medirComplexidade(codigo: string): Medida[] {
const fonte = ts.createSourceFile(
"medida.ts",
codigo,
ts.ScriptTarget.Latest,
true,
);
const medidas: Medida[] = [];
function medir(funcao: ts.FunctionLikeDeclaration): void {
let complexidade = 1;
const visitar = (no: ts.Node): void => {
if (ehFuncao(no)) {
medir(no);
return;
}
if (ehDecisao(no)) complexidade++;
ts.forEachChild(no, visitar);
};
ts.forEachChild(funcao, visitar);
const { line } = fonte.getLineAndCharacterOfPosition(funcao.getStart());
medidas.push({
nome: nomeDaFuncao(funcao),
linha: line + 1,
complexidade,
});
}
const percorrer = (no: ts.Node): void => {
if (ehFuncao(no)) medir(no);
else ts.forEachChild(no, percorrer);
};
percorrer(fonte);
return medidas.sort((a, b) => a.linha - b.linha);
}
function comDecisoes(n: number): string {
const ifs = Array.from({ length: n }, (_, i) => `if (x > ${i}) y++;`);
return `function f(x: number) { let y = 0; ${ifs.join(" ")} return y; }`;
}
describe("medirComplexidade", () => {
it("função sem decisão vale 1", () => {
expect(
medirComplexidade("function f() { return 1; }")[0].complexidade,
).toBe(1);
});
it("10 decisões valem 11 e são reprovadas", () => {
const [medida] = medirComplexidade(comDecisoes(10));
expect(medida.complexidade).toBe(11);
expect(medida.complexidade > LIMITE).toBe(true);
});
it("9 decisões valem 10 e passam", () => {
const [medida] = medirComplexidade(comDecisoes(9));
expect(medida.complexidade).toBe(10);
expect(medida.complexidade > LIMITE).toBe(false);
});
it("decisões de função aninhada não contam na de fora", () => {
const medidas = medirComplexidade(
"function fora(a: number) { const dentro = () => { if (a) return 1; return a ? 2 : 3; }; return dentro; }",
);
expect(medidas.find((m) => m.nome === "fora")?.complexidade).toBe(1);
expect(medidas.find((m) => m.nome === "dentro")?.complexidade).toBe(3);
});
it("default não conta, case conta", () => {
const [medida] = medirComplexidade(
"function f(x: number) { switch (x) { case 1: return 1; case 2: return 2; default: return 0; } }",
);
expect(medida.complexidade).toBe(3);
});
it("&&, || e ?? contam", () => {
const [medida] = medirComplexidade(
"function f(a: any, b: any, c: any) { return (a && b) || (c ?? a); }",
);
expect(medida.complexidade).toBe(4);
});
it("laços, catch, do e ternário contam; métodos são medidos", () => {
const medidas = medirComplexidade(
"class A { m(l: number[]) { for (const i of l) {} for (const k in l) {} for (;;) {} while (1) {} do {} while (0); try {} catch {} return l ? 1 : 2; } }",
);
expect(medidas[0]).toMatchObject({ nome: "m", complexidade: 8 });
});
});
describe("complexidade ciclomática de lib/jogo", () => {
it(`nenhuma função passa de ${LIMITE}`, () => {
const reprovadas: string[] = [];
const arquivos = readdirSync(PASTA).filter(
(f) => f.endsWith(".ts") && !f.endsWith(".test.ts"),
);
for (const arquivo of arquivos) {
const codigo = readFileSync(join(PASTA, arquivo), "utf8");
for (const m of medirComplexidade(codigo)) {
if (m.complexidade > LIMITE) {
reprovadas.push(`${arquivo}:${m.linha} ${m.nome} ${m.complexidade}`);
}
}
}
expect(reprovadas).toEqual([]);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { type Credito, validarCreditos } from "./creditos";
const ok: Credito = {
arquivo: "som/tiro.ogg",
origem: "https://exemplo.org/tiro",
autor: "Fulano",
licenca: "CC0-1.0",
};
describe("validarCreditos", () => {
it("não devolve erro quando está tudo certo", () => {
const propria: Credito = {
arquivo: "a.png",
origem: "interna",
autor: "Evandro",
licenca: "propria",
};
expect(validarCreditos(["som/tiro.ogg", "a.png"], [ok, propria])).toEqual(
[],
);
});
it("aponta arquivo sem crédito", () => {
expect(validarCreditos(["som/tiro.ogg", "b.png"], [ok])).toEqual([
"b.png: arquivo sem crédito.",
]);
});
it("aponta crédito de arquivo que não existe", () => {
expect(validarCreditos([], [ok])).toEqual([
"som/tiro.ogg: crédito de arquivo que não existe.",
]);
});
it("aponta licença não aceita", () => {
const erros = validarCreditos(
["som/tiro.ogg"],
[{ ...ok, licenca: "CC-BY-4.0" }],
);
expect(erros).toEqual(["som/tiro.ogg: licença não aceita (CC-BY-4.0)."]);
});
it("exige origem https:// para CC0-1.0", () => {
const erros = validarCreditos(
["som/tiro.ogg"],
[{ ...ok, origem: "http://exemplo.org" }],
);
expect(erros).toEqual([
"som/tiro.ogg: a origem de CC0-1.0 deve começar com https://.",
]);
});
it("aponta autor vazio", () => {
expect(validarCreditos(["som/tiro.ogg"], [{ ...ok, autor: " " }])).toEqual(
["som/tiro.ogg: autor vazio."],
);
});
it("aponta arquivo creditado duas vezes", () => {
expect(validarCreditos(["som/tiro.ogg"], [ok, ok])).toEqual([
"som/tiro.ogg: creditado mais de uma vez.",
]);
});
});
function listar(pasta: string, base = ""): string[] {
if (!existsSync(pasta)) return [];
return readdirSync(pasta).flatMap((nome) => {
const caminho = join(pasta, nome);
const rel = base ? `${base}/${nome}` : nome;
if (statSync(caminho).isDirectory()) return listar(caminho, rel);
return rel === "CREDITOS.json" ? [] : [rel];
});
}
describe("public/jogo", () => {
it("tem crédito válido para cada arquivo", () => {
const pasta = join(process.cwd(), "public", "jogo");
const json = join(pasta, "CREDITOS.json");
const creditos: Credito[] = existsSync(json)
? JSON.parse(readFileSync(json, "utf8"))
: [];
expect(validarCreditos(listar(pasta), creditos)).toEqual([]);
});
});
+45
View File
@@ -0,0 +1,45 @@
export type Credito = {
arquivo: string;
origem: string;
autor: string;
licenca: string;
};
export const LICENCAS_ACEITAS = ["CC0-1.0", "propria"] as const;
function errosDoCredito({
arquivo,
origem,
autor,
licenca,
}: Credito): string[] {
const erros: string[] = [];
if (!(LICENCAS_ACEITAS as readonly string[]).includes(licenca)) {
erros.push(`${arquivo}: licença não aceita (${licenca}).`);
}
if (licenca === "CC0-1.0" && !origem.startsWith("https://")) {
erros.push(`${arquivo}: a origem de CC0-1.0 deve começar com https://.`);
}
if (autor.trim() === "") erros.push(`${arquivo}: autor vazio.`);
return erros;
}
export function validarCreditos(
arquivos: string[],
creditos: Credito[],
): string[] {
const erros: string[] = [];
const vistos = new Set<string>();
for (const credito of creditos) {
if (vistos.has(credito.arquivo))
erros.push(`${credito.arquivo}: creditado mais de uma vez.`);
vistos.add(credito.arquivo);
if (!arquivos.includes(credito.arquivo))
erros.push(`${credito.arquivo}: crédito de arquivo que não existe.`);
erros.push(...errosDoCredito(credito));
}
for (const arquivo of arquivos) {
if (!vistos.has(arquivo)) erros.push(`${arquivo}: arquivo sem crédito.`);
}
return erros;
}
+23
View File
@@ -0,0 +1,23 @@
import { ajustar } from "./dificuldade";
import { ZUMBIS } from "./valores";
describe("ajustar", () => {
it("na dificuldade normal devolve os valores do tipo", () => {
expect(ajustar("blindado", "normal")).toEqual(ZUMBIS.blindado);
});
it("multiplica velocidade e dano no fácil", () => {
const r = ajustar("comum", "facil");
expect(r.velocidade).toBeCloseTo(1.2 * 0.6);
expect(r.dano).toBeCloseTo(10 * 0.4);
});
it("multiplica velocidade e dano no difícil sem mexer em vida, raio e altura", () => {
const r = ajustar("rapido", "dificil");
expect(r.velocidade).toBeCloseTo(2.4 * 1.3);
expect(r.dano).toBeCloseTo(6 * 1.5);
expect(r.vida).toBe(ZUMBIS.rapido.vida);
expect(r.raio).toBe(ZUMBIS.rapido.raio);
expect(r.altura).toBe(ZUMBIS.rapido.altura);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { Dificuldade, TipoZumbi } from "./tipos";
import { DIFICULDADES, ZUMBIS } from "./valores";
/** Os valores do tipo com velocidade e dano multiplicados pela dificuldade. */
export function ajustar(
tipo: TipoZumbi,
dificuldade: Dificuldade,
): {
vida: number;
velocidade: number;
dano: number;
raio: number;
altura: number;
} {
const base = ZUMBIS[tipo];
const nivel = DIFICULDADES[dificuldade];
return {
...base,
velocidade: base.velocidade * nivel.velocidade,
dano: base.dano * nivel.dano,
};
}
+235
View File
@@ -0,0 +1,235 @@
import { avancarDrones, criarDrone } from "./drones";
import { lerMapa } from "./mapa";
import { PROBLEMAS } from "./problemas";
import type { Drone, Partida, Zumbi } from "./tipos";
import { DRONE, ESCRITORIO, PASSO } from "./valores";
const PASSO_MAX = DRONE.velocidade * PASSO;
const [PROBLEMA_DRONE, PROBLEMA_ZUMBI] = PROBLEMAS;
function partida(x: number, z: number, rumo = 0): Partida {
return {
passo: 0,
rng: 1,
dificuldade: "normal",
problemaId: PROBLEMA_DRONE.id,
hordas: [],
horda: 0,
fase: "horda",
tempoFase: 0,
tetoAVista: 16,
mapa: lerMapa(ESCRITORIO),
jogador: {
x,
z,
rumo,
inclinacao: 0,
vida: 100,
arma: 0,
municao: 90,
recarga: 0,
},
zumbis: [],
fila: [],
drones: [],
caixas: [],
feed: [],
resolvidos: [],
placar: { jogador: 0, drones: 0 },
eventos: [],
proximoId: 1,
};
}
function zumbi(p: Partida, x: number, z: number, vida = 100): Zumbi {
const z1: Zumbi = {
id: p.proximoId++,
tipo: "comum",
problemaId: PROBLEMA_ZUMBI.id,
x,
z,
rumo: 0,
vida,
estado: "andando",
tempo: 0,
};
p.zumbis.push(z1);
return z1;
}
function drone(p: Partida, recarga = 1): Drone {
const d = criarDrone(p, PROBLEMA_DRONE.id);
d.recarga = recarga;
return d;
}
describe("criarDrone", () => {
it("nasce no jogador, com o rumo dele, e entra na lista", () => {
const p = partida(5.5, 8.5, 1.25);
const d = criarDrone(p, PROBLEMA_DRONE.id);
expect(d).toEqual({
id: 1,
problemaId: PROBLEMA_DRONE.id,
x: 5.5,
z: 8.5,
rumo: 1.25,
recarga: DRONE.intervalo,
eliminacoes: 0,
});
expect(p.drones).toEqual([d]);
expect(p.proximoId).toBe(2);
});
});
describe("avancarDrones: movimento", () => {
it("anda no máximo DRONE.velocidade * PASSO por passo e chega ao lugar", () => {
const p = partida(13.5, 8.5);
const d = drone(p, 1000);
for (let i = 0; i < 100; i++) {
const [x, z] = [d.x, d.z];
avancarDrones(p);
expect(Math.hypot(d.x - x, d.z - z)).toBeLessThanOrEqual(
PASSO_MAX + 1e-12,
);
}
expect(d.x).toBeCloseTo(15.5, 9);
expect(d.z).toBeCloseTo(8.5, 9);
});
it("dá um passo cheio quando o lugar está longe", () => {
const p = partida(13.5, 8.5);
const d = drone(p, 1000);
avancarDrones(p);
expect(d.x - 13.5).toBeCloseTo(PASSO_MAX, 9);
expect(d.z).toBeCloseTo(8.5, 9);
});
it("não atravessa parede", () => {
const p = partida(13.5, 15.5, -Math.PI / 2);
const d = drone(p, 1000);
for (let i = 0; i < 100; i++) avancarDrones(p);
expect(d.z).toBeLessThanOrEqual(16 - 0.2);
});
it("três drones ocupam ângulos diferentes em volta do jogador", () => {
const p = partida(24.5, 8.5, 0.4);
const drones = [drone(p, 1000), drone(p, 1000), drone(p, 1000)];
for (let i = 0; i < 200; i++) avancarDrones(p);
drones.forEach((d, i) => {
const a = p.jogador.rumo + Math.PI / 2 + i * ((2 * Math.PI) / 3);
expect(d.x).toBeCloseTo(24.5 + Math.sin(a) * DRONE.distancia, 6);
expect(d.z).toBeCloseTo(8.5 + Math.cos(a) * DRONE.distancia, 6);
});
expect(
Math.hypot(drones[0].x - drones[1].x, drones[0].z - drones[1].z),
).toBeGreaterThan(1);
expect(
Math.hypot(drones[1].x - drones[2].x, drones[1].z - drones[2].z),
).toBeGreaterThan(1);
});
});
describe("avancarDrones: tiro", () => {
it("respeita a recarga: desconta até 0, atira e recarrega", () => {
const p = partida(5.5, 8.5);
const d = drone(p, 3);
const alvo = zumbi(p, 11.5, 8.5);
avancarDrones(p);
avancarDrones(p);
expect(d.recarga).toBe(1);
expect(p.eventos).toEqual([]);
avancarDrones(p);
expect(d.recarga).toBe(DRONE.intervalo);
expect(p.eventos.map((e) => e.tipo)).toEqual(["tiro", "acerto"]);
expect(alvo.vida).toBe(100 - DRONE.dano);
avancarDrones(p);
expect(d.recarga).toBe(DRONE.intervalo - 1);
expect(p.eventos).toHaveLength(2);
expect(p.feed).toEqual([]);
});
it("vira para o zumbi e registra tiro e acerto", () => {
const p = partida(5.5, 8.5);
const d = drone(p);
const alvo = zumbi(p, 5.5, 14.5);
avancarDrones(p);
const distancia = Math.hypot(alvo.x - d.x, alvo.z - d.z);
expect(d.rumo).toBeCloseTo(Math.atan2(alvo.x - d.x, alvo.z - d.z), 9);
expect(p.eventos).toEqual([
{ tipo: "tiro", autor: d.id, arma: "drone", distancia },
{
tipo: "acerto",
zumbiId: alvo.id,
cabeca: false,
x: 5.5,
y: 1.2,
z: 14.5,
},
]);
});
it("escolhe o zumbi mais próximo", () => {
const p = partida(5.5, 8.5);
drone(p);
const longe = zumbi(p, 15.5, 8.5);
const perto = zumbi(p, 9.5, 8.5);
avancarDrones(p);
expect(perto.vida).toBe(100 - DRONE.dano);
expect(longe.vida).toBe(100);
});
it("mantém o mais próximo quando o seguinte está mais longe", () => {
const p = partida(5.5, 8.5);
drone(p);
const perto = zumbi(p, 9.5, 8.5);
const longe = zumbi(p, 15.5, 8.5);
avancarDrones(p);
expect(perto.vida).toBe(100 - DRONE.dano);
expect(longe.vida).toBe(100);
});
it("ignora zumbi morrendo", () => {
const p = partida(5.5, 8.5);
drone(p);
const morto = zumbi(p, 8.5, 8.5);
morto.estado = "morrendo";
const vivo = zumbi(p, 15.5, 8.5);
avancarDrones(p);
expect(morto.vida).toBe(100);
expect(vivo.vida).toBe(100 - DRONE.dano);
});
it("não atira com parede no meio e deixa a recarga em 0", () => {
const p = partida(9.5, 2.5);
const d = drone(p);
const alvo = zumbi(p, 12.5, 2.5);
avancarDrones(p);
expect(alvo.vida).toBe(100);
expect(p.eventos).toEqual([]);
expect(d.recarga).toBe(0);
});
it("não atira além do alcance", () => {
const p = partida(2.5, 8.5);
const d = drone(p);
const alvo = zumbi(p, 2.5 + DRONE.alcance + 1, 8.5);
avancarDrones(p);
expect(alvo.vida).toBe(100);
expect(p.eventos).toEqual([]);
expect(d.recarga).toBe(0);
});
it("a morte gera a linha do feed com a solução do drone e o nome do zumbi", () => {
const p = partida(5.5, 8.5);
drone(p);
const alvo = zumbi(p, 9.5, 8.5, DRONE.dano);
avancarDrones(p);
expect(alvo.estado).toBe("morrendo");
expect(p.feed).toEqual([
{
passo: 0,
texto: `${PROBLEMA_DRONE.solucao} eliminou ${PROBLEMA_ZUMBI.nome}`,
},
]);
});
});
+94
View File
@@ -0,0 +1,94 @@
/** Drones aliados: seguem o jogador em órbita e atiram no zumbi mais próximo à vista. */
import { registrarEliminacao } from "./feed";
import { moverComColisao, temLinhaDeVisao } from "./mapa";
import type { Drone, Partida, Zumbi } from "./tipos";
import { DRONE, PASSO } from "./valores";
import { ferir } from "./zumbis";
const RAIO_DO_DRONE = 0.2;
const PASSO_MAXIMO = DRONE.velocidade * PASSO;
const ANGULO_ENTRE_DRONES = (2 * Math.PI) / 3;
const ALTURA_DO_ACERTO = 1.2;
export function criarDrone(p: Partida, problemaId: string): Drone {
const drone: Drone = {
id: p.proximoId++,
problemaId,
x: p.jogador.x,
z: p.jogador.z,
rumo: p.jogador.rumo,
recarga: DRONE.intervalo,
eliminacoes: 0,
};
p.drones.push(drone);
return drone;
}
function seguirJogador(p: Partida, drone: Drone, indice: number): void {
const angulo = p.jogador.rumo + Math.PI / 2 + indice * ANGULO_ENTRE_DRONES;
const dx = p.jogador.x + Math.sin(angulo) * DRONE.distancia - drone.x;
const dz = p.jogador.z + Math.cos(angulo) * DRONE.distancia - drone.z;
const fracao = Math.min(1, PASSO_MAXIMO / Math.hypot(dx, dz));
[drone.x, drone.z] = moverComColisao(
p.mapa,
drone.x,
drone.z,
dx * fracao,
dz * fracao,
RAIO_DO_DRONE,
);
}
function zumbiMaisProximo(p: Partida, drone: Drone): Zumbi | undefined {
let alvo: Zumbi | undefined;
let melhor = Number.POSITIVE_INFINITY;
for (const zumbi of p.zumbis) {
const d = Math.hypot(zumbi.x - drone.x, zumbi.z - drone.z);
if (
zumbi.estado !== "morrendo" &&
d <= DRONE.alcance &&
d < melhor &&
temLinhaDeVisao(p.mapa, drone.x, drone.z, zumbi.x, zumbi.z)
) {
alvo = zumbi;
melhor = d;
}
}
return alvo;
}
function atirar(p: Partida, drone: Drone, alvo: Zumbi): void {
const dx = alvo.x - drone.x;
const dz = alvo.z - drone.z;
drone.rumo = Math.atan2(dx, dz);
p.eventos.push(
{
tipo: "tiro",
autor: drone.id,
arma: "drone",
distancia: Math.hypot(dx, dz),
},
{
tipo: "acerto",
zumbiId: alvo.id,
cabeca: false,
x: alvo.x,
y: ALTURA_DO_ACERTO,
z: alvo.z,
},
);
const matou = ferir(p, alvo, DRONE.dano, drone.id);
drone.recarga = DRONE.intervalo;
if (matou) registrarEliminacao(p, drone.problemaId, alvo.problemaId);
}
export function avancarDrones(p: Partida): void {
p.drones.forEach((drone, indice) => {
seguirJogador(p, drone, indice);
drone.recarga = Math.max(0, drone.recarga - 1);
if (drone.recarga > 0) return;
const alvo = zumbiMaisProximo(p, drone);
if (alvo) atirar(p, drone, alvo);
});
}
+48
View File
@@ -0,0 +1,48 @@
import { registrar, registrarEliminacao, registrarResolvido } from "./feed";
import type { Partida } from "./tipos";
const nova = (): Pick<Partida, "feed" | "passo"> => ({ feed: [], passo: 42 });
describe("registrar", () => {
it("acrescenta a linha com o passo atual", () => {
const p = nova();
registrar(p, "olá");
expect(p.feed).toEqual([{ passo: 42, texto: "olá" }]);
});
it("mantém cinco linhas e corta na sexta", () => {
const p = nova();
for (let i = 1; i <= 5; i++) registrar(p, `l${i}`);
expect(p.feed).toHaveLength(5);
registrar(p, "l6");
expect(p.feed.map((l) => l.texto)).toEqual(["l2", "l3", "l4", "l5", "l6"]);
});
});
describe("registrarEliminacao", () => {
it("usa a solução do drone e o nome do zumbi", () => {
const p = nova();
registrarEliminacao(p, "site-lento", "site-lento");
expect(p.feed[0].texto).toBe("Site rápido eliminou Site lento");
});
it("usa o próprio id quando desconhecido", () => {
const p = nova();
registrarEliminacao(p, "a", "b");
expect(p.feed[0].texto).toBe("a eliminou b");
});
});
describe("registrarResolvido", () => {
it("registra o nome do problema", () => {
const p = nova();
registrarResolvido(p, "site-lento");
expect(p.feed[0].texto).toBe("Resolvido: Site lento");
});
it("usa o próprio id quando desconhecido", () => {
const p = nova();
registrarResolvido(p, "zzz");
expect(p.feed[0].texto).toBe("Resolvido: zzz");
});
});
+26
View File
@@ -0,0 +1,26 @@
import { problemaPorId } from "./problemas";
import type { Partida } from "./tipos";
import { LINHAS_DO_FEED } from "./valores";
type ComFeed = Pick<Partida, "feed" | "passo">;
export function registrar(p: ComFeed, texto: string): void {
p.feed.push({ passo: p.passo, texto });
if (p.feed.length > LINHAS_DO_FEED) {
p.feed.splice(0, p.feed.length - LINHAS_DO_FEED);
}
}
export function registrarEliminacao(
p: ComFeed,
problemaDoDrone: string,
problemaDoZumbi: string,
): void {
const solucao = problemaPorId(problemaDoDrone)?.solucao ?? problemaDoDrone;
const nome = problemaPorId(problemaDoZumbi)?.nome ?? problemaDoZumbi;
registrar(p, `${solucao} eliminou ${nome}`);
}
export function registrarResolvido(p: ComFeed, problemaId: string): void {
registrar(p, `Resolvido: ${problemaPorId(problemaId)?.nome ?? problemaId}`);
}
+6
View File
@@ -0,0 +1,6 @@
{
"duracao": 1.4,
"impacto": 0.833,
"mao": "direita",
"alcance": 0.79
}
+113
View File
@@ -0,0 +1,113 @@
import { escolherHordas, montarFila, tamanhoDaHorda } from "./hordas";
import { problemaPorId, problemasDaArea } from "./problemas";
import { sortearInteiro } from "./rng";
import type { Dificuldade } from "./tipos";
describe("tamanhoDaHorda", () => {
it.each([
[0, "facil", 8],
[1, "facil", 11],
[2, "facil", 14],
[0, "normal", 12],
[1, "normal", 16],
[2, "normal", 20],
[0, "dificil", 16],
[1, "dificil", 21],
[2, "dificil", 26],
] as const)("horda %i no %s tem %i", (indice, dificuldade, esperado) => {
expect(tamanhoDaHorda(indice, dificuldade)).toBe(esperado);
});
it("lança erro para índice fora de 0 a 2", () => {
expect(() => tamanhoDaHorda(3, "normal")).toThrow(Error);
expect(() => tamanhoDaHorda(-1, "normal")).toThrow(Error);
});
});
describe("escolherHordas", () => {
const id = "site-lento";
const area = problemaPorId(id)?.area ?? "vender";
it("devolve 3 outros problemas da área, sem repetir, e o próprio id por último", () => {
for (let rng = 1; rng <= 30; rng++) {
const ids = escolherHordas({ rng }, id);
expect(ids).toHaveLength(4);
expect(ids[3]).toBe(id);
expect(new Set(ids).size).toBe(4);
for (const outro of ids) {
expect(problemaPorId(outro)?.area).toBe(area);
}
}
});
it("usa sortearInteiro sobre os candidatos restantes", () => {
const p = { rng: 7 };
const copia = { rng: 7 };
const outros = problemasDaArea(area)
.map((problema) => problema.id)
.filter((outro) => outro !== id);
const esperado: string[] = [];
for (let i = 0; i < 3; i++) {
const [escolhido] = outros.splice(
sortearInteiro(copia, outros.length),
1,
);
esperado.push(escolhido);
}
expect(escolherHordas(p, id)).toEqual([...esperado, id]);
});
it("lança erro para id inexistente", () => {
expect(() => escolherHordas({ rng: 1 }, "nao-existe")).toThrow(Error);
});
});
describe("montarFila", () => {
const tipos = (indice: number, dificuldade: Dificuldade = "normal") =>
montarFila(indice, "x", dificuldade).map((n) => n.tipo);
it("horda 0: todos comuns", () => {
expect(tipos(0)).toEqual(Array(12).fill("comum"));
});
it("horda 1: alterna comum e rápido", () => {
const t = tipos(1);
expect(t).toHaveLength(16);
expect(t.slice(0, 4)).toEqual(["comum", "rapido", "comum", "rapido"]);
expect(t.filter((x) => x === "comum")).toHaveLength(8);
expect(t.filter((x) => x === "rapido")).toHaveLength(8);
});
it("horda 2: posição módulo 5", () => {
const t = tipos(2);
expect(t).toHaveLength(20);
expect(t.slice(0, 5)).toEqual([
"comum",
"comum",
"rapido",
"rapido",
"blindado",
]);
expect(t.slice(15)).toEqual(t.slice(0, 5));
});
it("chefão: um chefão e a escolta fixa, sem mudar com a dificuldade", () => {
const esperado = ["chefao", ...Array(6).fill("comum")];
expect(tipos(3)).toEqual(esperado);
expect(tipos(3, "dificil")).toEqual(esperado);
});
it("o tamanho segue a dificuldade e todos levam o problemaId", () => {
const fila = montarFila(0, "abc", "facil");
expect(fila).toHaveLength(8);
expect(fila.every((n) => n.problemaId === "abc")).toBe(true);
expect(
montarFila(3, "abc", "facil").every((n) => n.problemaId === "abc"),
).toBe(true);
});
it("lança erro para índice fora de 0 a 3", () => {
expect(() => montarFila(4, "x", "normal")).toThrow(Error);
expect(() => montarFila(-1, "x", "normal")).toThrow(Error);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { problemaPorId, problemasDaArea } from "./problemas";
import { sortearInteiro } from "./rng";
import type { Dificuldade, NaFila, TipoZumbi } from "./tipos";
import { DIFICULDADES, HORDAS } from "./valores";
export function tamanhoDaHorda(
indice: number,
dificuldade: Dificuldade,
): number {
if (indice !== 0 && indice !== 1 && indice !== 2) {
throw new Error(`Índice de horda inválido: ${indice}`);
}
return Math.max(
1,
Math.round(HORDAS.tamanhos[indice] * DIFICULDADES[dificuldade].quantidade),
);
}
export function escolherHordas(
p: { rng: number },
problemaId: string,
): string[] {
const problema = problemaPorId(problemaId);
if (!problema) throw new Error(`Problema desconhecido: ${problemaId}`);
const outros = problemasDaArea(problema.area)
.map((candidato) => candidato.id)
.filter((id) => id !== problemaId);
const escolhidos: string[] = [];
for (let i = 0; i < 3; i++) {
escolhidos.push(...outros.splice(sortearInteiro(p, outros.length), 1));
}
return [...escolhidos, problemaId];
}
function tipoDaPosicao(indice: number, posicao: number): TipoZumbi {
if (indice === 0) return "comum";
if (indice === 1) return posicao % 2 === 0 ? "comum" : "rapido";
const resto = posicao % 5;
if (resto < 2) return "comum";
return resto < 4 ? "rapido" : "blindado";
}
export function montarFila(
indice: number,
problemaId: string,
dificuldade: Dificuldade,
): NaFila[] {
if (indice === 3) {
const tipos: TipoZumbi[] = [
"chefao",
...Array<TipoZumbi>(HORDAS.escoltaDoChefao).fill("comum"),
];
return tipos.map((tipo) => ({ tipo, problemaId }));
}
const total = tamanhoDaHorda(indice, dificuldade);
return Array.from({ length: total }, (_, posicao) => ({
tipo: tipoDaPosicao(indice, posicao),
problemaId,
}));
}
+309
View File
@@ -0,0 +1,309 @@
import {
caminhoLivre,
campoDeFluxo,
direcaoNoCampo,
ehOpaco,
ehSolido,
lerMapa,
moverComColisao,
raioAteParede,
temLinhaDeVisao,
} from "./mapa";
import { ESCRITORIO } from "./valores";
const SALA = lerMapa(["#######", "#J...N#", "#.....#", "#..M..#", "#######"]);
const DIVIDIDA = lerMapa(["#########", "#J..#..N#", "#...#...#", "#########"]);
describe("lerMapa", () => {
it("lê dimensões, células, início e nascedouros", () => {
const m = lerMapa(["#####", "#J.N#", "#.M.#", "#####"]);
expect(m.largura).toBe(5);
expect(m.altura).toBe(4);
expect(m.solido[2 * 5 + 2]).toBe(1);
expect(m.opaco[2 * 5 + 2]).toBe(0);
expect(m.solido[1 * 5 + 1]).toBe(0);
expect(m.opaco[0]).toBe(1);
expect(m.inicio).toEqual([1.5, 1.5]);
expect(m.nascedouros).toEqual([[3.5, 1.5]]);
});
it("recusa lista vazia", () => {
expect(() => lerMapa([])).toThrow(/ao menos uma linha/);
});
it("recusa linhas de larguras diferentes", () => {
expect(() => lerMapa(["JN", "N"])).toThrow(/larguras diferentes/);
});
it("recusa mapa sem início", () => {
expect(() => lerMapa(["N."])).toThrow(/exatamente um início/);
});
it("recusa mapa com dois inícios", () => {
expect(() => lerMapa(["JJN"])).toThrow(/exatamente um início/);
});
it("recusa mapa sem nascedouro", () => {
expect(() => lerMapa(["J."])).toThrow(/nascedouro/);
});
it("recusa caractere desconhecido", () => {
expect(() => lerMapa(["JNX"])).toThrow(/Caractere desconhecido "X"/);
});
it("lê o ESCRITORIO: 28×17, 4 nascedouros, todos com caminho até o início", () => {
const m = lerMapa(ESCRITORIO);
expect(m.largura).toBe(28);
expect(m.altura).toBe(17);
expect(m.nascedouros).toHaveLength(4);
const campo = campoDeFluxo(m, m.inicio[0], m.inicio[1]);
for (const [x, z] of m.nascedouros) {
expect(
campo[Math.floor(z) * m.largura + Math.floor(x)],
).toBeGreaterThanOrEqual(0);
}
});
});
describe("ehSolido e ehOpaco", () => {
it("parede é sólida e opaca", () => {
expect(ehSolido(SALA, 0.5, 0.5)).toBe(true);
expect(ehOpaco(SALA, 0.5, 0.5)).toBe(true);
});
it("mesa é sólida e não opaca", () => {
expect(ehSolido(SALA, 3.5, 3.5)).toBe(true);
expect(ehOpaco(SALA, 3.5, 3.5)).toBe(false);
});
it("chão é livre", () => {
expect(ehSolido(SALA, 2.5, 2.5)).toBe(false);
expect(ehOpaco(SALA, 2.5, 2.5)).toBe(false);
});
it.each([
[-0.1, 2.5],
[2.5, -0.1],
[7.1, 2.5],
[2.5, 5.1],
])("fora do mapa (%f, %f) é sólido e opaco", (x, z) => {
expect(ehSolido(SALA, x, z)).toBe(true);
expect(ehOpaco(SALA, x, z)).toBe(true);
});
});
describe("raioAteParede", () => {
it("anda em +x", () => {
expect(raioAteParede(SALA, 1.5, 2.5, Math.PI / 2, 99)).toBeCloseTo(4.5, 6);
});
it("anda em -x", () => {
expect(raioAteParede(SALA, 1.5, 2.5, -Math.PI / 2, 99)).toBeCloseTo(0.5, 6);
});
it("anda em +z atravessando a mesa", () => {
expect(raioAteParede(SALA, 3.5, 1.5, 0, 99)).toBeCloseTo(2.5, 6);
});
it("anda em -z", () => {
expect(raioAteParede(SALA, 3.5, 3.5, Math.PI, 99)).toBeCloseTo(2.5, 6);
});
it("anda em diagonal, trocando de eixo", () => {
expect(raioAteParede(SALA, 1.5, 1.5, 0.5, 99)).toBeCloseTo(
2.5 / Math.cos(0.5),
6,
);
});
it("devolve o alcance se não bater antes", () => {
expect(raioAteParede(SALA, 1.5, 2.5, Math.PI / 2, 2)).toBe(2);
});
it("devolve 0 se já começa em célula opaca", () => {
expect(raioAteParede(SALA, 0.5, 0.5, 1, 5)).toBe(0);
});
});
describe("temLinhaDeVisao", () => {
it("vê em campo aberto", () => {
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 3.5, 2.5)).toBe(true);
});
it("não vê através de parede", () => {
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 6.5, 1.5)).toBe(false);
});
it("vê através de mesa", () => {
expect(temLinhaDeVisao(SALA, 1.5, 3.5, 5.5, 3.5)).toBe(true);
});
it("não vê um ponto dentro da parede", () => {
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 4.5, 1.5)).toBe(false);
});
it("não vê a partir de dentro da parede", () => {
expect(temLinhaDeVisao(DIVIDIDA, 0.5, 0.5, 1.5, 1.5)).toBe(false);
});
it("o mesmo ponto livre se vê; o mesmo ponto em parede, não", () => {
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 1.5, 1.5)).toBe(true);
expect(temLinhaDeVisao(DIVIDIDA, 0.5, 0.5, 0.5, 0.5)).toBe(false);
});
});
/** Mesa em x de 3 a 4, z de 3 a 4. */
const COM_MESA = lerMapa([
"#######",
"#J....#",
"#.....#",
"#..M..#",
"#....N#",
"#######",
]);
describe("caminhoLivre", () => {
it("livre em campo aberto, com folga dos dois lados", () => {
expect(caminhoLivre(COM_MESA, 1.5, 1.5, 5.5, 1.5, 0.35)).toBe(true);
});
it("a mesa no meio do segmento bloqueia, mesmo sem bloquear a visão", () => {
expect(caminhoLivre(COM_MESA, 1.5, 3.5, 5.5, 3.5, 0.35)).toBe(false);
expect(temLinhaDeVisao(COM_MESA, 1.5, 3.5, 5.5, 3.5)).toBe(true);
});
it("bloqueia quando só o paralelo de um lado cruza a mesa", () => {
expect(caminhoLivre(COM_MESA, 1.5, 2.7, 5.5, 2.7, 0.35)).toBe(false);
expect(caminhoLivre(COM_MESA, 1.5, 2.7, 5.5, 2.7, 0.25)).toBe(true);
});
it("bloqueia quando só o paralelo do outro lado cruza a mesa", () => {
expect(caminhoLivre(COM_MESA, 1.5, 4.3, 5.5, 4.3, 0.35)).toBe(false);
expect(caminhoLivre(COM_MESA, 1.5, 4.3, 5.5, 4.3, 0.25)).toBe(true);
});
it("o mesmo ponto: livre em chão, bloqueado em célula sólida", () => {
expect(caminhoLivre(COM_MESA, 1.5, 1.5, 1.5, 1.5, 0.35)).toBe(true);
expect(caminhoLivre(COM_MESA, 3.5, 3.5, 3.5, 3.5, 0.35)).toBe(false);
});
});
describe("moverComColisao", () => {
it("anda livre nos dois eixos", () => {
const [x, z] = moverComColisao(SALA, 2.5, 2.5, 0.5, 0.2, 0.25);
expect(x).toBeCloseTo(3.0, 9);
expect(z).toBeCloseTo(2.7, 9);
});
it("desliza em z quando x bate na parede (canto de trás, em cima)", () => {
expect(moverComColisao(SALA, 1.5, 1.5, -0.5, 0.3, 0.25)).toEqual([
1.5, 1.8,
]);
});
it("desliza em x quando z bate na parede", () => {
const [x, z] = moverComColisao(SALA, 1.5, 1.5, 0.3, -0.5, 0.25);
expect(x).toBeCloseTo(1.8, 9);
expect(z).toBe(1.5);
});
it("para quando os dois eixos batem", () => {
expect(moverComColisao(SALA, 1.5, 1.5, -0.5, -0.5, 0.25)).toEqual([
1.5, 1.5,
]);
});
it("confere o canto da frente, em cima (parede à direita)", () => {
expect(moverComColisao(SALA, 5.5, 2.5, 0.5, 0.1, 0.25)).toEqual([5.5, 2.6]);
});
it("confere o canto de trás, embaixo (mesa por baixo)", () => {
expect(moverComColisao(SALA, 3.5, 2.5, 0, 0.3, 0.25)).toEqual([3.5, 2.5]);
});
it("confere o canto da frente, embaixo (mesa na diagonal)", () => {
const [x, z] = moverComColisao(SALA, 2.5, 2.5, 0.3, 0.3, 0.25);
expect(x).toBeCloseTo(2.8, 9);
expect(z).toBe(2.5);
});
});
const FLUXO = lerMapa(["######", "#J.#N#", "#..#.#", "#M.###", "######"]);
describe("campoDeFluxo", () => {
it("guarda distâncias, -1 em sólidas e inalcançáveis", () => {
const campo = campoDeFluxo(FLUXO, 1.5, 1.5);
expect(Array.from(campo)).toEqual([
...[-1, -1, -1, -1, -1, -1],
...[-1, 0, 1, -1, -1, -1],
...[-1, 1, 2, -1, -1, -1],
...[-1, -1, 3, -1, -1, -1],
...[-1, -1, -1, -1, -1, -1],
]);
expect(campo).toBeInstanceOf(Int16Array);
});
it("tudo -1 se o alvo está em célula sólida", () => {
expect(
Array.from(campoDeFluxo(FLUXO, 1.5, 3.5)).every((v) => v === -1),
).toBe(true);
});
it("tudo -1 se o alvo está fora do mapa", () => {
expect(
Array.from(campoDeFluxo(FLUXO, -3, 1.5)).every((v) => v === -1),
).toBe(true);
});
});
const ABERTO = lerMapa(["J..", "...", "..N"]);
describe("direcaoNoCampo", () => {
const campo = campoDeFluxo(ABERTO, 0.5, 0.5);
it("vai na diagonal quando livre", () => {
const [dx, dz] = direcaoNoCampo(ABERTO, campo, 2.5, 2.5);
expect(dx).toBeCloseTo(-Math.SQRT1_2, 9);
expect(dz).toBeCloseTo(-Math.SQRT1_2, 9);
});
it("aponta para o centro da melhor vizinha a partir da posição real", () => {
const [dx, dz] = direcaoNoCampo(ABERTO, campo, 2.9, 2.1);
const tamanho = Math.hypot(1.4, 0.6);
expect(dx).toBeCloseTo(-1.4 / tamanho, 9);
expect(dz).toBeCloseTo(-0.6 / tamanho, 9);
});
it("vai em linha reta na borda do mapa", () => {
expect(direcaoNoCampo(ABERTO, campo, 1.5, 0.5)).toEqual([-1, 0]);
});
it("devolve [0, 0] na célula do alvo", () => {
expect(direcaoNoCampo(ABERTO, campo, 0.5, 0.5)).toEqual([0, 0]);
});
it("devolve [0, 0] fora do mapa", () => {
expect(direcaoNoCampo(ABERTO, campo, -1, 0.5)).toEqual([0, 0]);
expect(direcaoNoCampo(ABERTO, campo, 0.5, -1)).toEqual([0, 0]);
expect(direcaoNoCampo(ABERTO, campo, 3.5, 0.5)).toEqual([0, 0]);
expect(direcaoNoCampo(ABERTO, campo, 0.5, 3.5)).toEqual([0, 0]);
});
it("devolve [0, 0] em célula sem caminho", () => {
const campoF = campoDeFluxo(FLUXO, 1.5, 1.5);
expect(direcaoNoCampo(FLUXO, campoF, 4.5, 1.5)).toEqual([0, 0]);
});
it("não corta quina: parede em cima da diagonal", () => {
const m = lerMapa(["J#.", "...", "..N"]);
const c = campoDeFluxo(m, 0.5, 0.5);
expect(direcaoNoCampo(m, c, 1.5, 1.5)).toEqual([-1, 0]);
});
it("não corta quina: parede ao lado da diagonal", () => {
const m = lerMapa(["J..", "#..", "..N"]);
const c = campoDeFluxo(m, 0.5, 0.5);
expect(direcaoNoCampo(m, c, 1.5, 1.5)).toEqual([0, -1]);
});
});
+308
View File
@@ -0,0 +1,308 @@
/** Grade do escritório: leitura, colisão, visão e caminho dos zumbis. Sem three, React nem DOM. */
import type { Mapa } from "./tipos";
type Ponto = readonly [number, number];
type Camada = "solido" | "opaco";
const CARACTERES = "#M.JN";
const SOLIDOS = "#M";
/** Tolerância de ponto flutuante na comparação de distâncias da visão. */
const FOLGA = 1e-9;
const VIZINHOS_4: readonly Ponto[] = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
];
const VIZINHOS_8: readonly Ponto[] = [
...VIZINHOS_4,
[1, 1],
[1, -1],
[-1, 1],
[-1, -1],
];
function validarLinhas(linhas: readonly string[]): void {
if (linhas.length === 0) {
throw new Error("O mapa precisa de ao menos uma linha.");
}
if (linhas.some((linha) => linha.length !== linhas[0].length)) {
throw new Error("As linhas do mapa têm larguras diferentes.");
}
}
export function lerMapa(linhas: readonly string[]): Mapa {
validarLinhas(linhas);
const largura = linhas[0].length;
const altura = linhas.length;
const solido = new Uint8Array(largura * altura);
const opaco = new Uint8Array(largura * altura);
const inicios: Ponto[] = [];
const nascedouros: Ponto[] = [];
for (let z = 0; z < altura; z++) {
for (let x = 0; x < largura; x++) {
const c = linhas[z].charAt(x);
if (!CARACTERES.includes(c)) {
throw new Error(
`Caractere desconhecido "${c}" no mapa (coluna ${x}, linha ${z}).`,
);
}
solido[z * largura + x] = SOLIDOS.includes(c) ? 1 : 0;
opaco[z * largura + x] = c === "#" ? 1 : 0;
if (c === "J") inicios.push([x + 0.5, z + 0.5]);
if (c === "N") nascedouros.push([x + 0.5, z + 0.5]);
}
}
if (inicios.length !== 1) {
throw new Error("O mapa precisa de exatamente um início (J).");
}
if (nascedouros.length === 0) {
throw new Error("O mapa precisa de ao menos um nascedouro (N).");
}
return { largura, altura, solido, opaco, inicio: inicios[0], nascedouros };
}
function dentro(mapa: Mapa, cx: number, cz: number): boolean {
return cx >= 0 && cz >= 0 && cx < mapa.largura && cz < mapa.altura;
}
function consultar(mapa: Mapa, camada: Camada, x: number, z: number): boolean {
const cx = Math.floor(x);
const cz = Math.floor(z);
if (!dentro(mapa, cx, cz)) return true;
return mapa[camada][cz * mapa.largura + cx] === 1;
}
/** Coordenadas em metros; fora do mapa é sólido. */
export function ehSolido(mapa: Mapa, x: number, z: number): boolean {
return consultar(mapa, "solido", x, z);
}
/** Coordenadas em metros; fora do mapa é opaco. */
export function ehOpaco(mapa: Mapa, x: number, z: number): boolean {
return consultar(mapa, "opaco", x, z);
}
type Eixo = { passo: number; delta: number; t: number };
/** Estado do DDA em um eixo: sentido, distância por célula e distância até a próxima borda. */
function prepararEixo(pos: number, d: number): Eixo {
if (d === 0) return { passo: 0, delta: Infinity, t: Infinity };
const base = Math.floor(pos);
const ate = d > 0 ? base + 1 - pos : pos - base;
return {
passo: d > 0 ? 1 : -1,
delta: 1 / Math.abs(d),
t: ate / Math.abs(d),
};
}
/** Distância até a primeira célula da camada na direção (sin(rumo), cos(rumo)); `alcance` se não bater antes. */
function distanciaAteCamada(
mapa: Mapa,
camada: Camada,
x: number,
z: number,
rumo: number,
alcance: number,
): number {
const ex = prepararEixo(x, Math.sin(rumo));
const ez = prepararEixo(z, Math.cos(rumo));
let cx = Math.floor(x);
let cz = Math.floor(z);
let t = 0;
while (t < alcance) {
if (consultar(mapa, camada, cx, cz)) return t;
if (ex.t < ez.t) {
t = ex.t;
ex.t += ex.delta;
cx += ex.passo;
} else {
t = ez.t;
ez.t += ez.delta;
cz += ez.passo;
}
}
return alcance;
}
/** Distância até a primeira célula opaca na direção (sin(rumo), cos(rumo)); `alcance` se não bater antes. */
export function raioAteParede(
mapa: Mapa,
x: number,
z: number,
rumo: number,
alcance: number,
): number {
return distanciaAteCamada(mapa, "opaco", x, z, rumo, alcance);
}
/** Verdadeiro se nenhuma célula da camada fica entre os dois pontos (inclusive o de chegada). */
function segmentoLivre(
mapa: Mapa,
camada: Camada,
x0: number,
z0: number,
x1: number,
z1: number,
): boolean {
if (consultar(mapa, camada, x1, z1)) return false;
const dx = x1 - x0;
const dz = z1 - z0;
const distancia = Math.hypot(dx, dz);
const rumo = Math.atan2(dx, dz);
return (
distanciaAteCamada(mapa, camada, x0, z0, rumo, distancia) >=
distancia - FOLGA
);
}
/** Verdadeiro se não há célula opaca entre os dois pontos (inclusive o de chegada). */
export function temLinhaDeVisao(
mapa: Mapa,
x0: number,
z0: number,
x1: number,
z1: number,
): boolean {
return segmentoLivre(mapa, "opaco", x0, z0, x1, z1);
}
/** Verdadeiro se o segmento e os dois paralelos deslocados `raio` para cada lado não cruzam célula sólida. */
export function caminhoLivre(
mapa: Mapa,
x0: number,
z0: number,
x1: number,
z1: number,
raio: number,
): boolean {
const distancia = Math.hypot(x1 - x0, z1 - z0);
const escala = distancia === 0 ? 0 : raio / distancia;
const nx = (z0 - z1) * escala;
const nz = (x1 - x0) * escala;
return [0, 1, -1].every((lado) =>
segmentoLivre(
mapa,
"solido",
x0 + nx * lado,
z0 + nz * lado,
x1 + nx * lado,
z1 + nz * lado,
),
);
}
function circuloLivre(mapa: Mapa, x: number, z: number, raio: number): boolean {
return !(
ehSolido(mapa, x - raio, z - raio) ||
ehSolido(mapa, x + raio, z - raio) ||
ehSolido(mapa, x - raio, z + raio) ||
ehSolido(mapa, x + raio, z + raio)
);
}
/** Tenta x e depois z, separadamente, então desliza na parede. */
export function moverComColisao(
mapa: Mapa,
x: number,
z: number,
dx: number,
dz: number,
raio: number,
): [number, number] {
const nx = circuloLivre(mapa, x + dx, z, raio) ? x + dx : x;
const nz = circuloLivre(mapa, nx, z + dz, raio) ? z + dz : z;
return [nx, nz];
}
function visitar(
mapa: Mapa,
campo: Int16Array,
fila: number[],
cx: number,
cz: number,
distancia: number,
): void {
if (ehSolido(mapa, cx, cz)) return;
const i = cz * mapa.largura + cx;
if (campo[i] !== -1) return;
campo[i] = distancia;
fila.push(i);
}
/** Distância em células até o alvo (BFS de 4 vizinhos); -1 para sólida ou inalcançável. */
export function campoDeFluxo(mapa: Mapa, x: number, z: number): Int16Array {
const campo = new Int16Array(mapa.largura * mapa.altura).fill(-1);
const fila: number[] = [];
visitar(mapa, campo, fila, Math.floor(x), Math.floor(z), 0);
for (let k = 0; k < fila.length; k++) {
const cx = fila[k] % mapa.largura;
const cz = Math.floor(fila[k] / mapa.largura);
for (const [ox, oz] of VIZINHOS_4) {
visitar(mapa, campo, fila, cx + ox, cz + oz, campo[fila[k]] + 1);
}
}
return campo;
}
function distanciaEm(
mapa: Mapa,
campo: Int16Array,
cx: number,
cz: number,
): number {
return dentro(mapa, cx, cz) ? campo[cz * mapa.largura + cx] : -1;
}
/** Na diagonal, só passa se as duas células ortogonais vizinhas estão livres. */
function quinaLivre(
mapa: Mapa,
cx: number,
cz: number,
ox: number,
oz: number,
): boolean {
return !(ehSolido(mapa, cx + ox, cz) || ehSolido(mapa, cx, cz + oz));
}
/** Vizinho (8 direções, sem cortar quina) de menor distância; undefined na célula do alvo ou sem caminho. */
function melhorVizinho(
mapa: Mapa,
campo: Int16Array,
cx: number,
cz: number,
): Ponto | undefined {
let melhorDistancia = distanciaEm(mapa, campo, cx, cz);
if (melhorDistancia <= 0) return undefined;
let melhor: Ponto = [0, 0];
for (const [ox, oz] of VIZINHOS_8) {
const d = distanciaEm(mapa, campo, cx + ox, cz + oz);
if (d < 0 || d >= melhorDistancia) continue;
if (!quinaLivre(mapa, cx, cz, ox, oz)) continue;
melhorDistancia = d;
melhor = [ox, oz];
}
return melhor;
}
/** Vetor unitário da posição real até o centro do melhor vizinho; [0, 0] na célula do alvo ou sem caminho. */
export function direcaoNoCampo(
mapa: Mapa,
campo: Int16Array,
x: number,
z: number,
): [number, number] {
const cx = Math.floor(x);
const cz = Math.floor(z);
const vizinho = melhorVizinho(mapa, campo, cx, cz);
if (!vizinho) return [0, 0];
const dx = cx + vizinho[0] + 0.5 - x;
const dz = cz + vizinho[1] + 0.5 - z;
const tamanho = Math.hypot(dx, dz);
return [dx / tamanho, dz / tamanho];
}
+131
View File
@@ -0,0 +1,131 @@
import { campoDeFluxo, direcaoNoCampo, lerMapa } from "../mapa";
import { AMBIENTES, ESCRITORIO, PORTAS } from "./escritorio";
const MAPA = lerMapa(ESCRITORIO);
const { largura, altura } = MAPA;
const andavel = (bloqueio: Uint8Array, x: number, z: number) =>
x >= 0 &&
z >= 0 &&
x < largura &&
z < altura &&
bloqueio[z * largura + x] === 0;
/** Quantas células andáveis a busca em largura alcança a partir do início. */
function alcancaveis(bloqueio: Uint8Array): number {
const inicio =
Math.floor(MAPA.inicio[1]) * largura + Math.floor(MAPA.inicio[0]);
const visto = new Uint8Array(largura * altura);
const fila = [inicio];
visto[inicio] = 1;
for (let k = 0; k < fila.length; k++) {
const x = fila[k] % largura;
const z = Math.floor(fila[k] / largura);
for (const [dx, dz] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
]) {
const i = (z + dz) * largura + x + dx;
if (andavel(bloqueio, x + dx, z + dz) && !visto[i]) {
visto[i] = 1;
fila.push(i);
}
}
}
return fila.length;
}
const totalAndavel = MAPA.solido.filter((c) => c === 0).length;
describe("mapa do escritório", () => {
it("tem pelo menos 40 × 28 células e é lido por lerMapa", () => {
expect(largura).toBeGreaterThanOrEqual(40);
expect(altura).toBeGreaterThanOrEqual(28);
expect(() => lerMapa(ESCRITORIO)).not.toThrow();
});
it("tem os 7 ambientes com nome", () => {
const nomes = AMBIENTES.map((a) => a.nome);
for (const nome of [
"recepção",
"área de mesas",
"sala de reunião",
"copa",
"sala do servidor",
"diretoria",
"almoxarifado",
]) {
expect(nomes).toContain(nome);
}
});
it("tem 6 ou mais nascedouros, todos a 8 m ou mais do início", () => {
expect(MAPA.nascedouros.length).toBeGreaterThanOrEqual(6);
for (const [x, z] of MAPA.nascedouros) {
expect(
Math.hypot(x - MAPA.inicio[0], z - MAPA.inicio[1]),
).toBeGreaterThanOrEqual(8);
}
});
it("alcança todo o chão a partir do início", () => {
expect(alcancaveis(MAPA.solido)).toBe(totalAndavel);
});
it("continua alcançando todo o chão sem qualquer uma das portas", () => {
expect(PORTAS.length).toBeGreaterThan(0);
for (const porta of PORTAS) {
const bloqueio = MAPA.solido.slice();
for (let z = porta.z; z < porta.z + porta.altura; z++) {
for (let x = porta.x; x < porta.x + porta.largura; x++) {
expect(andavel(bloqueio, x, z)).toBe(true);
bloqueio[z * largura + x] = 1;
}
}
expect(alcancaveis(bloqueio)).toBe(
totalAndavel - porta.largura * porta.altura,
);
}
});
it("não tem passagem com menos de 2 células: toda célula andável está num 2 × 2 andável", () => {
const livre = (x: number, z: number) => andavel(MAPA.solido, x, z);
for (let z = 0; z < altura; z++) {
for (let x = 0; x < largura; x++) {
if (!livre(x, z)) continue;
const emQuadrado = [
[-1, -1],
[-1, 0],
[0, -1],
[0, 0],
].some(
([ox, oz]) =>
livre(x + ox, z + oz) &&
livre(x + ox + 1, z + oz) &&
livre(x + ox, z + oz + 1) &&
livre(x + ox + 1, z + oz + 1),
);
expect([x, z, emQuadrado]).toEqual([x, z, true]);
}
}
});
it("o campo de fluxo leva cada nascedouro ao início em menos de 400 passos", () => {
const campo = campoDeFluxo(MAPA, MAPA.inicio[0], MAPA.inicio[1]);
const alvo = [Math.floor(MAPA.inicio[0]), Math.floor(MAPA.inicio[1])];
for (const [nx, nz] of MAPA.nascedouros) {
let x = Math.floor(nx);
let z = Math.floor(nz);
let passos = 0;
while ((x !== alvo[0] || z !== alvo[1]) && passos < 400) {
const [dx, dz] = direcaoNoCampo(MAPA, campo, x + 0.5, z + 0.5);
x += Math.round(dx);
z += Math.round(dz);
passos++;
}
expect([x, z]).toEqual(alvo);
expect(passos).toBeLessThan(400);
}
});
});
+89
View File
@@ -0,0 +1,89 @@
// 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[] = [
"############################################",
"#..MMM.........#............#MMM..........##",
"#..............#............#.............##",
"#M......MMMM...#..MMMMMMMM.........MMMM...##",
"#M.............#..MMMMMMMM.........MMMM...##",
"#M.....J.......#............#..............#",
"#M.............#............#..............#",
"####..######..####..####..####..############",
"#........#........................#........#",
"#........#........................#........#",
"#........#..#########..#####..##..#..#######",
"#M..MM......#.................##...........#",
"#M..MM......#.................##...........#",
"#M.......#..#..MMMMM...MMMMM..##..#..#######",
"#M.......#..#..MMMMM...MMMMM...#..#........#",
"#MMMM..###........................#........#",
"##########........................####..####",
"###..MMMM#..#..MMMMM...MMMMM...#..#.......##",
"###......#..#..MMMMM...MMMMM...#..#.......##",
"###.........#..................#..........##",
"###.........#..................#.....###...#",
"###...N..#..#########..#########..#........#",
"#........#........................#......N.#",
"#........#........................###......#",
"####..#######..#####..######..########..####",
"#................#......#..................#",
"#................#......#..................#",
"#.N....N.............####...............MMM#",
"#....................####...............MMM#",
"####..###........#.N.####.N................#",
"####..###..MMMM..#...####..######........N.#",
"############################################",
];
/** Salas com nome, em células (x para a direita, z para baixo). */
export const AMBIENTES: readonly {
nome: string;
x: number;
z: number;
largura: number;
altura: number;
}[] = [
{ nome: "recepção", x: 1, z: 1, largura: 14, altura: 6 },
{ nome: "sala de reunião", x: 16, z: 1, largura: 12, altura: 6 },
{ nome: "diretoria", x: 29, z: 1, largura: 14, altura: 6 },
{ nome: "copa", x: 1, z: 8, largura: 8, altura: 8 },
{ nome: "banheiros", x: 1, z: 17, largura: 8, altura: 7 },
{ nome: "área de mesas", x: 13, z: 11, largura: 18, altura: 10 },
{ nome: "sala do servidor", x: 35, z: 8, largura: 8, altura: 8 },
{ nome: "almoxarifado", x: 35, z: 17, largura: 8, altura: 7 },
{ nome: "hall dos elevadores", x: 1, z: 25, largura: 16, altura: 6 },
{ nome: "escada", x: 18, z: 25, largura: 6, altura: 6 },
{ nome: "área de serviço", x: 25, z: 25, largura: 18, altura: 6 },
];
/** Células abertas nas paredes, em células. */
export const PORTAS: readonly {
x: number;
z: number;
largura: number;
altura: number;
}[] = [
{ x: 4, z: 7, largura: 2, altura: 1 },
{ x: 12, z: 7, largura: 2, altura: 1 },
{ x: 18, z: 7, largura: 2, altura: 1 },
{ x: 24, z: 7, largura: 2, altura: 1 },
{ x: 28, z: 3, largura: 1, altura: 2 },
{ x: 30, z: 7, largura: 2, altura: 1 },
{ x: 9, z: 11, largura: 1, altura: 2 },
{ x: 9, z: 19, largura: 1, altura: 2 },
{ x: 4, z: 24, largura: 2, altura: 1 },
{ x: 13, z: 24, largura: 2, altura: 1 },
{ x: 17, z: 27, largura: 1, altura: 2 },
{ x: 20, z: 24, largura: 2, altura: 1 },
{ x: 28, z: 24, largura: 2, altura: 1 },
{ x: 38, z: 24, largura: 2, altura: 1 },
{ x: 34, z: 19, largura: 1, altura: 2 },
{ x: 38, z: 16, largura: 2, altura: 1 },
{ x: 34, z: 11, largura: 1, altura: 2 },
{ x: 21, z: 10, largura: 2, altura: 1 },
{ x: 28, z: 10, largura: 2, altura: 1 },
{ x: 21, z: 21, largura: 2, altura: 1 },
{ x: 12, z: 15, largura: 1, altura: 2 },
{ x: 31, z: 15, largura: 1, altura: 2 },
];
+192
View File
@@ -0,0 +1,192 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const RAIZ = process.cwd();
const PASTA = join(RAIZ, "public/jogo");
const BLOCO = join(RAIZ, "public/3d/bloco.glb");
const MB = 1024 * 1024;
function arquivos(pasta: string): string[] {
if (!existsSync(pasta)) return [];
return readdirSync(pasta, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? arquivos(join(pasta, e.name)) : [join(pasta, e.name)],
);
}
const nome = (caminho: string) => relative(RAIZ, caminho);
const doJogo = arquivos(PASTA).filter((a) => !a.endsWith("CREDITOS.json"));
const glbs = [...doJogo, BLOCO].filter((a) => a.endsWith(".glb"));
/** Maior lado de um JPEG, PNG ou WebP, lido do cabeçalho; `null` se não for nenhum deles. */
function lado(b: Buffer): number | null {
if (
b.length > 30 &&
b.toString("latin1", 0, 4) === "RIFF" &&
b.toString("latin1", 8, 15) === "WEBPVP8"
) {
const tipo = b.toString("latin1", 15, 16);
if (tipo === "X")
return Math.max(b.readUIntLE(24, 3), b.readUIntLE(27, 3)) + 1;
if (tipo === " ")
return Math.max(b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff);
if (tipo === "L") {
const v = b.readUInt32LE(21);
return Math.max((v & 0x3fff) + 1, ((v >> 14) & 0x3fff) + 1);
}
}
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47)
return Math.max(b.readUInt32BE(16), b.readUInt32BE(20));
if (b[0] === 0xff && b[1] === 0xd8) {
let i = 2;
while (i + 9 < b.length) {
if (b[i] !== 0xff) {
i += 1;
continue;
}
const marcador = b[i + 1];
if (marcador === 0xff) {
i += 1;
continue;
}
if (marcador === 0xd8 || (marcador >= 0xd0 && marcador <= 0xd7)) {
i += 2;
continue;
}
const sof =
marcador >= 0xc0 &&
marcador <= 0xcf &&
marcador !== 0xc4 &&
marcador !== 0xc8 &&
marcador !== 0xcc;
if (sof) return Math.max(b.readUInt16BE(i + 5), b.readUInt16BE(i + 7));
i += 2 + b.readUInt16BE(i + 2);
}
}
return null;
}
type Gltf = {
accessors?: Array<{ count: number }>;
meshes?: Array<{ primitives: Array<{ indices?: number }> }>;
images?: Array<{ bufferView?: number; uri?: string }>;
bufferViews?: Array<{ byteOffset?: number; byteLength: number }>;
};
function lerGlb(caminho: string) {
const b = readFileSync(caminho);
const tamanhoJson = b.readUInt32LE(12);
const json = JSON.parse(
b.subarray(20, 20 + tamanhoJson).toString("utf8"),
) as Gltf;
const fimJson = 20 + tamanhoJson;
const bin =
fimJson + 8 <= b.length
? b.subarray(fimJson + 8, fimJson + 8 + b.readUInt32LE(fimJson))
: Buffer.alloc(0);
return { json, bin };
}
describe("orçamento de peso do jogo", () => {
it("public/jogo + bloco.glb somam até 6 MB", () => {
const total = [...doJogo, BLOCO].reduce((s, a) => s + statSync(a).size, 0);
expect({
total: `${(total / MB).toFixed(2)} MB`,
ok: total <= 6 * MB,
}).toEqual({ total: `${(total / MB).toFixed(2)} MB`, ok: true });
});
it.each(glbs.map((g) => [nome(g), g]))("%s tem até 1,5 MB", (n, g) => {
const tamanho = statSync(g).size;
expect({
arquivo: n,
MB: +(tamanho / MB).toFixed(2),
ok: tamanho <= 1.5 * MB,
}).toEqual({ arquivo: n, MB: +(tamanho / MB).toFixed(2), ok: true });
});
it("zumbi.glb tem até 12.000 triângulos", () => {
const { json } = lerGlb(join(PASTA, "zumbi.glb"));
let triangulos = 0;
for (const malha of json.meshes ?? [])
for (const p of malha.primitives)
if (p.indices !== undefined)
triangulos += (json.accessors?.[p.indices]?.count ?? 0) / 3;
expect({ arquivo: "public/jogo/zumbi.glb", triangulos }).toEqual({
arquivo: "public/jogo/zumbi.glb",
triangulos: Math.min(triangulos, 12000),
});
});
it("imagens de public/jogo têm lado até 1024 px", () => {
for (const a of doJogo.filter(
(f) => /\.(jpe?g|png|webp)$/i.test(f) && !f.endsWith("capa.jpg"),
)) {
const l = lado(readFileSync(a));
expect({ arquivo: nome(a), lado: l }).toEqual({
arquivo: nome(a),
lado: Math.min(l ?? 0, 1024),
});
}
});
it("itens.webp tem 512 px de lado e até 120 KB", () => {
const a = join(PASTA, "itens.webp");
const kb = statSync(a).size / 1024;
expect({
arquivo: "public/jogo/itens.webp",
lado: lado(readFileSync(a)),
ok: kb <= 120,
}).toEqual({ arquivo: "public/jogo/itens.webp", lado: 512, ok: true });
});
it("capa.jpg tem lado até 1600 px e até 220 KB", () => {
const a = join(PASTA, "capa.jpg");
const l = lado(readFileSync(a));
const kb = statSync(a).size / 1024;
expect({
arquivo: "public/jogo/capa.jpg",
lado: l,
KB: Math.round(kb),
}).toEqual({
arquivo: "public/jogo/capa.jpg",
lado: Math.min(l ?? 0, 1600),
KB: Math.min(Math.round(kb), 220),
});
});
it("imagens embutidas nos GLB têm lado até 1024 px", () => {
for (const g of glbs) {
const { json, bin } = lerGlb(g);
(json.images ?? []).forEach((img, i) => {
let dados: Buffer | null = null;
if (img.bufferView !== undefined) {
const v = json.bufferViews?.[img.bufferView];
if (v)
dados = bin.subarray(
v.byteOffset ?? 0,
(v.byteOffset ?? 0) + v.byteLength,
);
} else if (img.uri?.startsWith("data:"))
dados = Buffer.from(img.uri.split(",")[1] ?? "", "base64");
const l = dados ? lado(dados) : null;
const rotulo = `${nome(g)}#imagem${i}`;
expect({ arquivo: rotulo, lado: l }).toEqual({
arquivo: rotulo,
lado: Math.min(l ?? 0, 1024),
});
});
}
});
it("cada .mp3 de public/jogo/sons tem até 60 KB", () => {
for (const a of arquivos(join(PASTA, "sons")).filter((f) =>
f.endsWith(".mp3"),
)) {
const kb = statSync(a).size / 1024;
expect({ arquivo: nome(a), KB: Math.round(kb) }).toEqual({
arquivo: nome(a),
KB: Math.min(Math.round(kb), 60),
});
}
});
});
+379
View File
@@ -0,0 +1,379 @@
import { criarDrone } from "./drones";
import { montarFila } from "./hordas";
import * as mapaModulo from "./mapa";
import { avancar, criarPartida } from "./partida";
import { pilotar } from "./piloto";
import { semear } from "./rng";
import type { Dificuldade, Entrada, Fase, Partida } from "./tipos";
import { ESCRITORIO, HORDAS, JOGADOR, MUNICAO, PASSO } from "./valores";
const MAPA = ["#########", "#N......#", "#.......#", "#......J#", "#########"];
const PARADO: Entrada = {
mover: [0, 0],
olhar: [0, 0],
atirar: false,
arma: 0,
};
function nova(extra: Partial<Parameters<typeof criarPartida>[0]> = {}) {
return criarPartida({
semente: 1,
dificuldade: "facil",
problemaId: "site-lento",
tetoAVista: 2,
mapa: MAPA,
...extra,
});
}
function passo(p: Partida, parcial: Partial<Entrada> = {}) {
avancar(p, { ...PARADO, ...parcial });
}
function em(p: Partida, fase: Fase, extra: Partial<Partida> = {}) {
p.fase = fase;
p.tempoFase = 0;
p.jogador.x = 4.5;
p.jogador.z = 2.5;
Object.assign(p, extra);
}
const FILA_UM = [{ tipo: "comum" as const, problemaId: "site-lento" }];
describe("criarPartida", () => {
it("monta o estado inicial no mapa do escritório por padrão", () => {
const p = criarPartida({
semente: 7,
dificuldade: "normal",
problemaId: "site-lento",
});
expect(p.fase).toBe("inicio");
expect(p.tetoAVista).toBe(16);
expect(p.mapa.largura).toBe(ESCRITORIO[0].length);
expect(p.jogador).toEqual({
x: p.mapa.inicio[0],
z: p.mapa.inicio[1],
rumo: 0,
inclinacao: 0,
vida: JOGADOR.vida,
arma: 0,
municao: MUNICAO.inicial,
recarga: 0,
});
expect(p.hordas).toHaveLength(4);
expect(p.hordas[3]).toBe("site-lento");
expect(p).toMatchObject({
passo: 0,
horda: 0,
tempoFase: 0,
zumbis: [],
fila: [],
drones: [],
caixas: [],
feed: [],
resolvidos: [],
placar: { jogador: 0, drones: 0 },
eventos: [],
proximoId: 1,
problemaId: "site-lento",
dificuldade: "normal",
});
});
it("usa o mapa e o teto informados e consome o sorteio da própria partida", () => {
const p = nova({ tetoAVista: 3 });
expect(p.tetoAVista).toBe(3);
expect(p.mapa.largura).toBe(9);
expect(p.jogador.x).toBe(7.5);
expect(p.rng).not.toBe(semear(1));
expect(nova().hordas).toEqual(p.hordas);
});
});
describe("avancar: jogador", () => {
it("anda para a frente em +z com rumo 0", () => {
const p = nova();
em(p, "pausa");
passo(p, { mover: [0, 1] });
expect(p.jogador.x).toBeCloseTo(4.5);
expect(p.jogador.z).toBeCloseTo(2.5 + JOGADOR.velocidade * PASSO);
});
it("anda para a direita em −x com rumo 0", () => {
const p = nova();
em(p, "pausa");
passo(p, { mover: [1, 0] });
expect(p.jogador.x).toBeCloseTo(4.5 - JOGADOR.velocidade * PASSO);
expect(p.jogador.z).toBeCloseTo(2.5);
});
it("gira o rumo e anda para a frente na direção nova", () => {
const p = nova();
em(p, "pausa");
passo(p, { olhar: [Math.PI / 2, 0], mover: [0, 1] });
expect(p.jogador.rumo).toBeCloseTo(Math.PI / 2);
expect(p.jogador.x).toBeCloseTo(4.5 + JOGADOR.velocidade * PASSO);
expect(p.jogador.z).toBeCloseTo(2.5);
});
it("limita o movimento diagonal a comprimento 1", () => {
const p = nova();
em(p, "pausa");
passo(p, { mover: [1, 1] });
const andou = Math.hypot(p.jogador.x - 4.5, p.jogador.z - 2.5);
expect(andou).toBeCloseTo(JOGADOR.velocidade * PASSO);
});
it("não aumenta um movimento menor que 1", () => {
const p = nova();
em(p, "pausa");
passo(p, { mover: [0, 0.5] });
expect(p.jogador.z).toBeCloseTo(2.5 + 0.5 * JOGADOR.velocidade * PASSO);
});
it("soma a inclinação e a limita para cima e para baixo", () => {
const p = nova();
em(p, "pausa");
passo(p, { olhar: [0, 0.1] });
expect(p.jogador.inclinacao).toBeCloseTo(0.1);
passo(p, { olhar: [0, 5] });
expect(p.jogador.inclinacao).toBe(JOGADOR.inclinacaoMax);
passo(p, { olhar: [0, -9] });
expect(p.jogador.inclinacao).toBe(-JOGADOR.inclinacaoMax);
});
it("troca de arma, mas só atira se pedido", () => {
const p = nova();
em(p, "pausa");
passo(p, { arma: 1 });
expect(p.jogador.arma).toBe(1);
expect(p.eventos).toEqual([]);
p.jogador.recarga = 0;
passo(p, { arma: 1, atirar: true });
expect(p.eventos[0]).toMatchObject({ tipo: "tiro", autor: "jogador" });
});
it("recolhe as caixas ao alcance", () => {
const p = nova();
em(p, "pausa", { caixas: [{ id: 99, x: 4.5, z: 2.5 }] });
passo(p);
expect(p.caixas).toEqual([]);
expect(p.jogador.municao).toBe(MUNICAO.inicial + MUNICAO.porCaixa);
expect(p.eventos).toEqual([{ tipo: "caixa", balas: MUNICAO.porCaixa }]);
});
});
describe("avancar: fases", () => {
it("esvazia os eventos e conta passos", () => {
const p = nova();
p.eventos.push({ tipo: "golpe", dano: 1, zumbiId: 0 });
passo(p);
expect(p.eventos).toEqual([]);
expect(p.passo).toBe(1);
expect(p.tempoFase).toBe(1);
});
it("fica em inicio até o preparo acabar", () => {
const p = nova();
p.tempoFase = HORDAS.preparo - 2;
passo(p);
expect(p.fase).toBe("inicio");
expect(p.eventos).toEqual([]);
});
it("começa a horda 0 ao fim do preparo", () => {
const p = nova();
p.tempoFase = HORDAS.preparo - 1;
passo(p);
expect(p.fase).toBe("horda");
expect(p.tempoFase).toBe(0);
expect(p.horda).toBe(0);
expect(p.fila).toEqual(montarFila(0, p.hordas[0], "facil"));
expect(p.eventos).toEqual([{ tipo: "fase", fase: "horda" }]);
});
it("não termina a horda com zumbis na fila", () => {
const p = nova();
em(p, "horda", { fila: [...FILA_UM], tetoAVista: 0 });
passo(p);
expect(p.fase).toBe("horda");
expect(p.resolvidos).toEqual([]);
});
it("não termina a horda com zumbis no mapa", () => {
const p = nova();
em(p, "horda", {
zumbis: [
{
id: 50,
tipo: "comum",
problemaId: "site-lento",
x: 1.5,
z: 1.5,
rumo: 0,
vida: 100,
estado: "andando",
tempo: 0,
},
],
});
passo(p);
expect(p.fase).toBe("horda");
expect(p.resolvidos).toEqual([]);
});
it("termina a horda: resolve, cria o drone, cura e vai para a pausa", () => {
const p = nova();
em(p, "horda", { horda: 1 });
p.jogador.vida = 50;
passo(p);
const resolvido = p.hordas[1];
expect(p.resolvidos).toEqual([resolvido]);
expect(p.drones).toHaveLength(1);
expect(p.drones[0].problemaId).toBe(resolvido);
expect(p.jogador.vida).toBe(50 + JOGADOR.curaPorHorda);
expect(p.fase).toBe("pausa");
expect(p.tempoFase).toBe(0);
expect(p.feed[0].texto).toMatch(/^Resolvido: /);
expect(p.eventos).toEqual([
{ tipo: "resolvido", problemaId: resolvido, droneId: p.drones[0].id },
{ tipo: "fase", fase: "pausa" },
]);
});
it("a cura não passa da vida máxima", () => {
const p = nova();
em(p, "horda");
p.jogador.vida = 90;
passo(p);
expect(p.jogador.vida).toBe(JOGADOR.vida);
});
it("vai para a derrota com a vida zerada, antes de resolver a horda", () => {
const p = nova();
em(p, "horda");
p.jogador.vida = 0;
passo(p);
expect(p.fase).toBe("derrota");
expect(p.resolvidos).toEqual([]);
expect(p.drones).toEqual([]);
expect(p.eventos).toEqual([{ tipo: "fase", fase: "derrota" }]);
});
it("fica na pausa até o tempo acabar, e os drones seguem o jogador", () => {
const p = nova();
em(p, "pausa", { horda: 0 });
const drone = criarDrone(p, "site-lento");
drone.x = 1.5;
drone.z = 1.5;
passo(p);
expect(p.fase).toBe("pausa");
expect(p.horda).toBe(0);
expect(drone.x).toBeGreaterThan(1.5);
expect(p.eventos).toEqual([]);
});
it("sai da pausa para a horda seguinte", () => {
const p = nova();
em(p, "pausa", { horda: 0 });
p.tempoFase = HORDAS.pausa - 1;
passo(p);
expect(p.horda).toBe(1);
expect(p.fase).toBe("horda");
expect(p.tempoFase).toBe(0);
expect(p.fila).toEqual(montarFila(1, p.hordas[1], "facil"));
expect(p.eventos).toEqual([{ tipo: "fase", fase: "horda" }]);
});
it("sai da pausa da horda 2 para o chefão", () => {
const p = nova();
em(p, "pausa", { horda: 2 });
p.tempoFase = HORDAS.pausa - 1;
passo(p);
expect(p.horda).toBe(3);
expect(p.fase).toBe("chefao");
expect(p.fila).toEqual(montarFila(3, p.hordas[3], "facil"));
expect(p.eventos).toEqual([{ tipo: "fase", fase: "chefao" }]);
});
it("vence ao derrotar o chefão, sem criar drone", () => {
const p = nova();
em(p, "chefao", { horda: 3 });
passo(p);
expect(p.fase).toBe("vitoria");
expect(p.resolvidos).toEqual(["site-lento"]);
expect(p.drones).toEqual([]);
expect(p.eventos).toEqual([
{ tipo: "resolvido", problemaId: "site-lento", droneId: -1 },
{ tipo: "fase", fase: "vitoria" },
]);
});
it("perde no chefão com a vida zerada", () => {
const p = nova();
em(p, "chefao", { horda: 3 });
p.jogador.vida = -5;
passo(p);
expect(p.fase).toBe("derrota");
});
it.each(["vitoria", "derrota"] as const)(
"depois da %s nada mais muda, só os eventos esvaziam",
(fase) => {
const p = nova();
em(p, fase);
p.eventos.push({ tipo: "golpe", dano: 1, zumbiId: 0 });
const antes = JSON.stringify({ ...p, eventos: [] });
passo(p, { mover: [1, 1], olhar: [1, 1], atirar: true, arma: 1 });
expect(p.eventos).toEqual([]);
expect(JSON.stringify(p)).toBe(antes);
},
);
});
describe("avancar: campo de fluxo", () => {
it("só recalcula quando o jogador muda de célula", () => {
const espiao = jest.spyOn(mapaModulo, "campoDeFluxo");
const p = nova();
em(p, "horda", { fila: [...FILA_UM], tetoAVista: 0 });
passo(p);
passo(p);
expect(espiao).toHaveBeenCalledTimes(1);
p.jogador.x -= 1;
passo(p);
expect(espiao).toHaveBeenCalledTimes(2);
passo(p);
expect(espiao).toHaveBeenCalledTimes(2);
espiao.mockRestore();
});
});
describe("partida inteira com o piloto", () => {
const TETO_DE_PASSOS = 60_000;
// regressão: na fácil, as sementes 4, 6, 13, 20, 23, 24 e 38 empacavam na horda 3 com "faltam 2"
it.each<Dificuldade>(["facil", "normal", "dificil"])(
"40 sementes na %s terminam em vitória ou derrota",
(dificuldade) => {
const fins: [number, Fase][] = [];
for (let semente = 1; semente <= 40; semente++) {
const p = criarPartida({
semente,
dificuldade,
problemaId: "site-lento",
});
for (
let i = 0;
i < TETO_DE_PASSOS && p.fase !== "vitoria" && p.fase !== "derrota";
i++
) {
avancar(p, pilotar(p));
}
fins.push([semente, p.fase]);
}
const presas = fins.filter(([, f]) => f !== "vitoria" && f !== "derrota");
expect(presas).toEqual([]);
},
);
});
+164
View File
@@ -0,0 +1,164 @@
/** Integra a lógica do jogo: cria a partida e avança um passo por vez. Sem three, React nem DOM. */
import { atirar, recolherCaixas, resfriar, trocarArma } from "./armas";
import { avancarDrones, criarDrone } from "./drones";
import { registrarResolvido } from "./feed";
import { escolherHordas, montarFila } from "./hordas";
import { campoDeFluxo, lerMapa, moverComColisao } from "./mapa";
import { semear } from "./rng";
import type { Dificuldade, Entrada, Fase, Partida } from "./tipos";
import {
ESCRITORIO,
HORDAS,
JOGADOR,
MUNICAO,
PASSO,
TETO_A_VISTA,
} from "./valores";
import { avancarZumbis, nascer } from "./zumbis";
const HORDA_DO_CHEFAO = 3;
export function criarPartida(opcoes: {
semente: number;
dificuldade: Dificuldade;
problemaId: string;
tetoAVista?: number;
mapa?: readonly string[];
}): Partida {
const sorteio = { rng: semear(opcoes.semente) };
const hordas = escolherHordas(sorteio, opcoes.problemaId);
const mapa = lerMapa(opcoes.mapa ?? ESCRITORIO);
return {
passo: 0,
rng: sorteio.rng,
dificuldade: opcoes.dificuldade,
problemaId: opcoes.problemaId,
hordas,
horda: 0,
fase: "inicio",
tempoFase: 0,
tetoAVista: opcoes.tetoAVista ?? TETO_A_VISTA,
mapa,
jogador: {
x: mapa.inicio[0],
z: mapa.inicio[1],
rumo: 0,
inclinacao: 0,
vida: JOGADOR.vida,
arma: 0,
municao: MUNICAO.inicial,
recarga: 0,
},
zumbis: [],
fila: [],
drones: [],
caixas: [],
feed: [],
resolvidos: [],
placar: { jogador: 0, drones: 0 },
eventos: [],
proximoId: 1,
};
}
const campos = new WeakMap<Partida, { celula: number; campo: Int16Array }>();
/** Só recalcula o campo de fluxo quando o jogador muda de célula. */
function campoAtual(p: Partida): Int16Array {
const { x, z } = p.jogador;
const celula = Math.floor(z) * p.mapa.largura + Math.floor(x);
const guardado = campos.get(p);
if (guardado?.celula === celula) return guardado.campo;
const campo = campoDeFluxo(p.mapa, x, z);
campos.set(p, { celula, campo });
return campo;
}
function moverJogador(p: Partida, entrada: Entrada): void {
const j = p.jogador;
j.rumo += entrada.olhar[0];
j.inclinacao = Math.min(
JOGADOR.inclinacaoMax,
Math.max(-JOGADOR.inclinacaoMax, j.inclinacao + entrada.olhar[1]),
);
const [lado, frente] = entrada.mover;
const escala = Math.min(1, 1 / Math.hypot(lado, frente));
const passo = escala * JOGADOR.velocidade * PASSO;
const dx = (Math.sin(j.rumo) * frente - Math.cos(j.rumo) * lado) * passo;
const dz = (Math.cos(j.rumo) * frente + Math.sin(j.rumo) * lado) * passo;
[j.x, j.z] = moverComColisao(p.mapa, j.x, j.z, dx, dz, JOGADOR.raio);
}
function usarArma(p: Partida, entrada: Entrada): void {
trocarArma(p, entrada.arma);
resfriar(p);
if (entrada.atirar) atirar(p);
recolherCaixas(p);
}
function mudarFase(p: Partida, fase: Fase): void {
p.fase = fase;
p.eventos.push({ tipo: "fase", fase });
}
/** Começa a horda `p.horda`: a última é a do chefão. */
function comecarHorda(p: Partida): void {
p.tempoFase = 0;
p.fila = montarFila(p.horda, p.hordas[p.horda], p.dificuldade);
mudarFase(p, p.horda === HORDA_DO_CHEFAO ? "chefao" : "horda");
}
function fecharHorda(p: Partida): void {
const problemaId = p.hordas[p.horda];
p.resolvidos.push(problemaId);
registrarResolvido(p, problemaId);
if (p.fase === "chefao") {
p.eventos.push({ tipo: "resolvido", problemaId, droneId: -1 });
mudarFase(p, "vitoria");
return;
}
const drone = criarDrone(p, problemaId);
p.eventos.push({ tipo: "resolvido", problemaId, droneId: drone.id });
p.jogador.vida = Math.min(
JOGADOR.vida,
p.jogador.vida + JOGADOR.curaPorHorda,
);
p.tempoFase = 0;
mudarFase(p, "pausa");
}
function combater(p: Partida): void {
nascer(p);
avancarZumbis(p, campoAtual(p));
avancarDrones(p);
if (p.jogador.vida <= 0) {
mudarFase(p, "derrota");
} else if (p.fila.length === 0 && p.zumbis.length === 0) {
fecharHorda(p);
}
}
function avancarFase(p: Partida): void {
if (p.fase === "inicio") {
if (p.tempoFase >= HORDAS.preparo) comecarHorda(p);
} else if (p.fase === "pausa") {
avancarDrones(p);
if (p.tempoFase >= HORDAS.pausa) {
p.horda++;
comecarHorda(p);
}
} else {
combater(p);
}
}
export function avancar(p: Partida, entrada: Entrada): void {
p.eventos.length = 0;
if (p.fase === "vitoria" || p.fase === "derrota") return;
p.passo++;
p.tempoFase++;
moverJogador(p, entrada);
usarArma(p, entrada);
avancarFase(p);
}
+226
View File
@@ -0,0 +1,226 @@
import { avancar, criarPartida } from "./partida";
import { pilotar } from "./piloto";
import type { Partida, TipoZumbi, Zumbi } from "./tipos";
import { ESCRITORIO, ZUMBIS } from "./valores";
/** 21 x 15, parede por dentro em x = 10, de z = 5 a z = 9. */
const MAPA = Array.from({ length: 15 }, (_, z) => {
if (z === 0 || z === 14) return "#".repeat(21);
const miolo = Array.from({ length: 19 }, (_, i) => {
if (z === 1 && i === 0) return "N";
if (z === 2 && i === 0) return "J";
return i === 9 && z >= 5 && z <= 9 ? "#" : ".";
});
return `#${miolo.join("")}#`;
});
const JX = 5.5;
const JZ = 7.5;
function zumbi(
id: number,
x: number,
z: number,
tipo: TipoZumbi = "comum",
estado: Zumbi["estado"] = "andando",
): Zumbi {
return {
id,
tipo,
problemaId: "site-lento",
x,
z,
rumo: 0,
vida: ZUMBIS[tipo].vida,
estado,
tempo: 0,
};
}
/** Jogador em (5.5, 7.5), rumo 0, com os zumbis dados. */
function cena(...zumbis: Zumbi[]): Partida {
const p = criarPartida({
semente: 1,
dificuldade: "facil",
problemaId: "site-lento",
mapa: MAPA,
});
p.jogador.x = JX;
p.jogador.z = JZ;
p.zumbis = zumbis;
return p;
}
/** Zumbi a `distancia` metros do jogador, no rumo dado. */
function aoRumo(
id: number,
rumo: number,
distancia: number,
tipo: TipoZumbi = "comum",
): Zumbi {
return zumbi(
id,
JX + Math.sin(rumo) * distancia,
JZ + Math.cos(rumo) * distancia,
tipo,
);
}
describe("pilotar: sem alvo", () => {
it("fica parado sem zumbis", () => {
expect(pilotar(cena())).toEqual({
mover: [0, 0],
olhar: [0, 0],
atirar: false,
arma: 0,
});
});
it("ignora zumbis morrendo", () => {
const p = cena(zumbi(1, 5.5, 9.5, "comum", "morrendo"));
expect(pilotar(p).atirar).toBe(false);
expect(pilotar(p).mover).toEqual([0, 0]);
});
});
describe("pilotar: escolha do alvo", () => {
it("prefere o mais próximo com linha de visão", () => {
const atras = zumbi(1, 11.5, 7.5);
const livre = zumbi(2, 1.5, 1.5);
expect(pilotar(cena(atras, livre)).olhar[0]).toBe(-0.12);
});
it("sem visão de ninguém, escolhe o mais próximo", () => {
const perto = zumbi(1, 11.5, 7.5);
const longe = zumbi(2, 11.5, 5.5);
expect(pilotar(cena(longe, perto)).olhar[0]).toBe(0.12);
});
it("não atira sem linha de visão, mesmo alinhado", () => {
const p = cena(zumbi(1, 11.5, 7.5));
p.jogador.rumo = Math.PI / 2;
expect(pilotar(p).atirar).toBe(false);
});
});
describe("pilotar: mira", () => {
it("gira no máximo 0,12 rad por passo", () => {
expect(pilotar(cena(aoRumo(1, 1, 5))).olhar[0]).toBe(0.12);
expect(pilotar(cena(aoRumo(1, -1, 5))).olhar[0]).toBe(-0.12);
});
it("gira só o que falta quando o erro é menor que o limite", () => {
const p = cena(aoRumo(1, 0.03, 5));
expect(pilotar(p).olhar[0]).toBeCloseTo(0.03);
});
it("normaliza a diferença de rumo para −π a π", () => {
const p = cena(aoRumo(1, -3, 5));
p.jogador.rumo = 3;
expect(pilotar(p).olhar[0]).toBe(0.12);
});
it("inclina no máximo 0,05 rad por passo, para baixo e para cima", () => {
const p = cena(aoRumo(1, 0, 5));
expect(pilotar(p).olhar[1]).toBe(-0.05);
p.jogador.inclinacao = -1;
expect(pilotar(p).olhar[1]).toBe(0.05);
});
it("mira a 65% da altura do tipo", () => {
const p = cena(aoRumo(1, 0, 5));
p.jogador.inclinacao = Math.atan2(0.65 * ZUMBIS.comum.altura - 1.6, 5);
expect(pilotar(p).olhar[1]).toBeCloseTo(0);
});
it("atira quando o erro de rumo é menor que 0,05 e há visão", () => {
expect(pilotar(cena(aoRumo(1, 0.04, 5))).atirar).toBe(true);
});
it("não atira com erro de rumo de 0,05 ou mais", () => {
expect(pilotar(cena(aoRumo(1, 0.06, 5))).atirar).toBe(false);
});
});
describe("pilotar: arma", () => {
it("usa a faca quando a borda está a 1,3 m ou menos", () => {
const comum = ZUMBIS.comum.raio;
expect(pilotar(cena(aoRumo(1, 0, 1.25 + comum))).arma).toBe(2);
expect(pilotar(cena(aoRumo(1, 0, 1.4 + comum))).arma).toBe(0);
});
it("usa a automática com 3 zumbis a até 12 m", () => {
const tres = cena(aoRumo(1, 0, 5), aoRumo(2, 1, 6), aoRumo(3, 2, 12));
expect(pilotar(tres).arma).toBe(1);
});
it("fica na pistola com só 2 zumbis a até 12 m", () => {
const dois = cena(aoRumo(1, 0, 5), aoRumo(2, 1, 6), aoRumo(3, 2, 12.5));
expect(pilotar(dois).arma).toBe(0);
});
it("usa a automática contra blindado e contra chefão", () => {
expect(pilotar(cena(aoRumo(1, 0, 6, "blindado"))).arma).toBe(1);
expect(pilotar(cena(aoRumo(1, 0, 6, "chefao"))).arma).toBe(1);
});
it("fica na pistola contra um comum ou um rápido", () => {
expect(pilotar(cena(aoRumo(1, 0, 6, "rapido"))).arma).toBe(0);
});
it("sem bala não usa a automática", () => {
const p = cena(aoRumo(1, 0, 6, "blindado"));
p.jogador.municao = 0;
expect(pilotar(p).arma).toBe(0);
});
});
describe("pilotar: distância", () => {
it("anda para trás com o alvo a menos de 4 m", () => {
expect(pilotar(cena(aoRumo(1, 0, 3.9))).mover).toEqual([0, -1]);
});
it("fica parado com o alvo a 4 m ou mais", () => {
expect(pilotar(cena(aoRumo(1, 0, 4))).mover).toEqual([0, 0]);
});
});
describe("pilotar: partida inteira no escritório", () => {
function jogar(dificuldade: Partida["dificuldade"] = "facil") {
const p = criarPartida({
semente: 1,
dificuldade,
problemaId: "site-lento",
mapa: ESCRITORIO,
});
while (p.fase !== "vitoria" && p.fase !== "derrota" && p.passo < 36000) {
avancar(p, pilotar(p));
}
return p;
}
it("vence em menos de 36.000 passos, com 3 drones e 4 problemas resolvidos", () => {
const p = jogar();
expect(p.fase).toBe("vitoria");
expect(p.passo).toBeLessThan(36000);
expect(p.drones).toHaveLength(3);
expect(p.resolvidos).toHaveLength(4);
expect(p.placar.drones).toBeGreaterThan(0);
});
it.each(["normal", "dificil"] as const)(
"termina em vitória ou derrota antes de 36.000 passos no %s",
(dificuldade) => {
const p = jogar(dificuldade);
expect(["vitoria", "derrota"]).toContain(p.fase);
expect(p.passo).toBeLessThan(36000);
},
);
it("é determinística: mesma partida, mesmo passo e mesmo placar", () => {
const a = jogar();
const b = jogar();
expect(b.passo).toBe(a.passo);
expect(b.placar).toEqual(a.placar);
});
});
+75
View File
@@ -0,0 +1,75 @@
/** Jogador automático para testes e medição: função pura do estado, sem memória própria. */
import { temLinhaDeVisao } from "./mapa";
import type { ArmaId, Entrada, Partida, Zumbi } from "./tipos";
import { JOGADOR, ZUMBIS } from "./valores";
const GIRO_MAXIMO = 0.12;
const INCLINACAO_MAXIMA = 0.05;
const ALTURA_DA_MIRA = 0.65;
const ALCANCE_DA_FACA = 1.3;
const ERRO_PARA_ATIRAR = 0.05;
const DISTANCIA_DE_RECUO = 4;
const RAIO_DA_MULTIDAO = 12;
const TAMANHO_DA_MULTIDAO = 3;
const PISTOLA: ArmaId = 0;
const AUTOMATICA: ArmaId = 1;
const FACA: ArmaId = 2;
type Alvo = { zumbi: Zumbi; vivos: Zumbi[]; visao: boolean };
function distanciaAte(p: Partida, zumbi: Zumbi): number {
return Math.hypot(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z);
}
function escolherAlvo(p: Partida): Alvo | undefined {
const { x, z } = p.jogador;
const vivos = p.zumbis.filter((zumbi) => zumbi.estado !== "morrendo");
if (vivos.length === 0) return undefined;
const vistos = vivos.filter((zumbi) =>
temLinhaDeVisao(p.mapa, x, z, zumbi.x, zumbi.z),
);
const lista = vistos.length > 0 ? vistos : vivos;
const zumbi = lista.reduce((a, b) =>
distanciaAte(p, b) < distanciaAte(p, a) ? b : a,
);
return { zumbi, vivos, visao: vistos.length > 0 };
}
function limitar(valor: number, maximo: number): number {
return Math.max(-maximo, Math.min(maximo, valor));
}
function escolherArma(p: Partida, alvo: Alvo): ArmaId {
const { tipo } = alvo.zumbi;
if (distanciaAte(p, alvo.zumbi) - ZUMBIS[tipo].raio <= ALCANCE_DA_FACA) {
return FACA;
}
const perto = alvo.vivos.filter(
(zumbi) => distanciaAte(p, zumbi) <= RAIO_DA_MULTIDAO,
);
const exige =
perto.length >= TAMANHO_DA_MULTIDAO ||
tipo === "blindado" ||
tipo === "chefao";
return p.jogador.municao > 0 && exige ? AUTOMATICA : PISTOLA;
}
export function pilotar(p: Partida): Entrada {
const alvo = escolherAlvo(p);
if (!alvo) return { mover: [0, 0], olhar: [0, 0], atirar: false, arma: 0 };
const { zumbi } = alvo;
const { x, z, rumo, inclinacao } = p.jogador;
const distancia = distanciaAte(p, zumbi);
const desvio = Math.atan2(zumbi.x - x, zumbi.z - z) - rumo;
const erro = Math.atan2(Math.sin(desvio), Math.cos(desvio));
const altura = ZUMBIS[zumbi.tipo].altura * ALTURA_DA_MIRA - JOGADOR.olhos;
const subida = Math.atan2(altura, distancia) - inclinacao;
return {
mover: [0, distancia < DISTANCIA_DE_RECUO ? -1 : 0],
olhar: [limitar(erro, GIRO_MAXIMO), limitar(subida, INCLINACAO_MAXIMA)],
atirar: Math.abs(erro) < ERRO_PARA_ATIRAR && alvo.visao,
arma: escolherArma(p, alvo),
};
}
+78
View File
@@ -0,0 +1,78 @@
import { getServicePage } from "@/lib/site-content";
import {
AREAS,
type AreaId,
PROBLEMAS,
problemaPorId,
problemasDaArea,
} from "./problemas";
const areas = Object.keys(AREAS) as AreaId[];
describe("PROBLEMAS", () => {
it("tem 27 problemas", () => {
expect(PROBLEMAS).toHaveLength(27);
});
it("tem ids únicos em kebab-case sem acento", () => {
const ids = PROBLEMAS.map(({ id }) => id);
expect(new Set(ids).size).toBe(ids.length);
for (const id of ids) expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
});
it("tem os rótulos das seis áreas", () => {
expect(AREAS).toEqual({
vender: "Vender e ser encontrado",
atender: "Atender",
operar: "Operar",
fiscal: "Fiscal e financeiro",
sistemas: "Sistemas e integrações",
seguranca: "Segurança e dados",
});
});
it("dá a toda área ao menos 4 problemas", () => {
for (const area of areas)
expect(problemasDaArea(area).length).toBeGreaterThanOrEqual(4);
});
it("aponta cada destino para uma página de serviço que existe", () => {
const fora = PROBLEMAS.filter(
({ destino }) => !destino.startsWith("/servicos/"),
);
expect(fora.map(({ destino }) => destino)).toEqual(["/ia"]);
for (const { destino } of PROBLEMAS.filter((p) =>
p.destino.startsWith("/servicos/"),
)) {
expect(getServicePage(destino.replace("/servicos/", ""))).toBeDefined();
}
});
it("não cita nome de cliente", () => {
const clientes = /SEV|Lavação|NutriScan|SC Plus|AS Serviços|Julia/;
for (const { nome, solucao } of PROBLEMAS) {
expect(nome).not.toMatch(clientes);
expect(solucao).not.toMatch(clientes);
}
});
});
describe("problemaPorId", () => {
it("acha pelo id", () => {
expect(problemaPorId(PROBLEMAS[0].id)).toBe(PROBLEMAS[0]);
});
it("devolve undefined para id inexistente", () => {
expect(problemaPorId("nao-existe")).toBeUndefined();
});
});
describe("problemasDaArea", () => {
it("devolve só os da área", () => {
for (const area of areas) {
const lista = problemasDaArea(area);
expect(lista.every((p) => p.area === area)).toBe(true);
}
expect(areas.reduce((n, a) => n + problemasDaArea(a).length, 0)).toBe(27);
});
});
+192
View File
@@ -0,0 +1,192 @@
export type AreaId =
| "vender"
| "atender"
| "operar"
| "fiscal"
| "sistemas"
| "seguranca";
export type Problema = {
id: string;
area: AreaId;
nome: string;
solucao: string;
destino: string;
};
export const AREAS: Record<AreaId, string> = {
vender: "Vender e ser encontrado",
atender: "Atender",
operar: "Operar",
fiscal: "Fiscal e financeiro",
sistemas: "Sistemas e integrações",
seguranca: "Segurança e dados",
};
const DESTINO_DA_AREA: Record<AreaId, string> = {
vender: "/servicos/criacao-de-sites",
atender: "/servicos/atendimento-whatsapp-ia",
operar: "/servicos/engenharia-de-software",
fiscal: "/servicos/nota-fiscal-reforma-tributaria",
sistemas: "/servicos/arquitetura-de-software",
seguranca: "/servicos/arquitetura-de-software",
};
function p(
area: AreaId,
id: string,
nome: string,
solucao: string,
destino = DESTINO_DA_AREA[area],
): Problema {
return { id, area, nome, solucao, destino };
}
export const PROBLEMAS: readonly Problema[] = [
p(
"vender",
"site-que-ninguem-acha",
"Site que ninguém acha no Google",
"Site que aparece no Google",
),
p(
"vender",
"site-ruim-no-celular",
"Site que não abre direito no celular",
"Site que funciona no celular",
),
p("vender", "site-lento", "Site lento", "Site rápido"),
p(
"vender",
"catalogo-desatualizado",
"Catálogo desatualizado",
"Catálogo em dia",
),
p(
"vender",
"pedido-perdido-no-whatsapp",
"Pedido perdido no WhatsApp",
"Pedido organizado",
),
p(
"vender",
"pagamento-que-falha",
"Pagamento que falha",
"Pagamento funcionando",
),
p(
"atender",
"atraso-no-atendimento",
"Atraso no atendimento",
"Atendimento na hora",
),
p(
"atender",
"mesma-pergunta-cem-vezes",
"Mesma pergunta respondida cem vezes",
"Resposta automática",
),
p(
"atender",
"agenda-duplicada",
"Agenda com horário duplicado",
"Agenda organizada",
),
p("atender", "cliente-sem-retorno", "Cliente sem retorno", "Retorno em dia"),
p(
"operar",
"planilha-duplicada",
"Planilha duplicada",
"Sistema no lugar da planilha",
),
p(
"operar",
"digitar-duas-vezes",
"Digitar a mesma coisa em dois lugares",
"Cadastro único",
),
p("operar", "estoque-furado", "Estoque furado", "Estoque em dia"),
p(
"operar",
"processo-de-uma-pessoa",
"Processo que só uma pessoa sabe fazer",
"Processo no sistema",
),
p("operar", "controle-no-papel", "Controle no papel", "Controle digital"),
p(
"fiscal",
"nota-fiscal-rejeitada",
"Nota fiscal rejeitada",
"Notas fiscais em dia",
),
p(
"fiscal",
"fechamento-que-nao-bate",
"Fechamento do mês que não bate",
"Fechamento conferido",
),
p(
"fiscal",
"cobranca-esquecida",
"Cobrança esquecida",
"Cobrança automática",
),
p(
"fiscal",
"relatorio-demorado",
"Relatório que demora dias",
"Relatório na hora",
),
p(
"sistemas",
"sistemas-que-nao-conversam",
"Sistemas que não conversam",
"Sistemas integrados",
),
p(
"sistemas",
"maquina-que-nao-conversa",
"Máquina que não conversa com o sistema",
"Equipamento integrado",
"/servicos/integracao-hardware-software-iot",
),
p(
"sistemas",
"sistema-antigo",
"Sistema antigo em que ninguém mexe",
"Sistema atualizado",
),
p(
"sistemas",
"sistema-que-cai-no-pico",
"Sistema que cai na hora do pico",
"Sistema estável",
),
p(
"seguranca",
"senha-compartilhada",
"Senha compartilhada",
"Acesso por pessoa",
),
p("seguranca", "dados-sem-backup", "Dados sem backup", "Backup automático"),
p(
"seguranca",
"acesso-de-ex-funcionario",
"Acesso de ex-funcionário",
"Acesso revogado",
),
p(
"seguranca",
"app-de-ia-com-falha",
"App feito por IA cheio de falha",
"App revisado",
"/ia",
),
];
export function problemaPorId(id: string): Problema | undefined {
return PROBLEMAS.find((problema) => problema.id === id);
}
export function problemasDaArea(area: AreaId): Problema[] {
return PROBLEMAS.filter((problema) => problema.area === area);
}
+157
View File
@@ -0,0 +1,157 @@
import {
amostrar,
criarRegulador,
nivelAtual,
type Regulador,
} from "./regulador";
import { NIVEIS } from "./valores";
const ULTIMO = NIVEIS.length - 1;
const HZ_60 = 1000 / 60;
const HZ_30 = 1000 / 30;
/** Alimenta `n` janelas inteiras (2 s cada) de quadros iguais; para antes se o regulador travar. */
function janelas(
r: Regulador,
intervalo: number,
trabalho: number,
n: number,
): number {
for (let i = 0; i < n && !r.travado; i++) {
do amostrar(r, intervalo, trabalho);
while (r.somaDaJanela > 0);
}
return r.nivel;
}
/** Regulador com o topo no nível 0 (tela de pixelRatio 2), já no nível `nivel`. */
function reguladorEm(nivel: number): Regulador {
const r = criarRegulador(2);
r.nivel = nivel;
return r;
}
describe("regulador", () => {
it("começa no primeiro nível cujo pixelRatio cabe no do aparelho", () => {
expect(criarRegulador(3).nivel).toBe(0);
expect(criarRegulador(2).nivel).toBe(0);
expect(criarRegulador(1.5).nivel).toBe(1);
expect(criarRegulador(1.25).nivel).toBe(2);
expect(criarRegulador(1).nivel).toBe(2);
// zoom de afastar: abaixo de 1 vale o primeiro degrau de pixelRatio 1
expect(criarRegulador(0.8).nivel).toBe(2);
});
it("nunca sobe acima do nível inicial do aparelho", () => {
const r = criarRegulador(1);
expect(janelas(r, 8, 2, 20)).toBe(2);
r.nivel = 4;
expect(janelas(r, 8, 2, 20)).toBe(2);
});
it("nivelAtual devolve o degrau de NIVEIS", () => {
expect(nivelAtual(reguladorEm(2))).toBe(NIVEIS[2]);
});
it("60 Hz com folga nunca muda o nível 0", () => {
expect(janelas(criarRegulador(2), HZ_60, 3, 30)).toBe(0);
});
it("60 Hz saudável sobe depois de cair", () => {
const r = criarRegulador(2);
expect(janelas(r, 30, 25, 1)).toBe(1);
// a janela depois da descida já ganhou e conta como folga: 5 janelas (10 s) sobem
expect(janelas(r, HZ_60, 3, 4)).toBe(1);
expect(janelas(r, HZ_60, 3, 1)).toBe(0);
expect(r.travado).toBe(false);
});
it("60 Hz com trabalho pesado não conta folga", () => {
const r = reguladorEm(2);
expect(janelas(r, HZ_60, 8, 10)).toBe(2);
});
it("tela limitada a 30 Hz desce 2, volta 2 e trava", () => {
const r = criarRegulador(2);
expect(janelas(r, HZ_30, 3, 1)).toBe(1);
expect(janelas(r, HZ_30, 3, 1)).toBe(2);
expect(janelas(r, HZ_30, 3, 1)).toBe(0);
expect(r.travado).toBe(true);
expect(janelas(r, HZ_30, 3, 1)).toBe(0);
expect(amostrar(r, HZ_30, 3)).toBe(0);
expect(r.intervalos).toHaveLength(0);
});
it("carga real desce e fica", () => {
const r = criarRegulador(2);
expect(janelas(r, 30, 25, 1)).toBe(1);
// cada descida melhora o intervalo em mais de 10%
expect(janelas(r, 24, 20, 1)).toBe(2);
expect(janelas(r, 18, 15, 15)).toBe(2);
expect(r.travado).toBe(false);
});
it("descida com ganho zera a conta de descidas sem ganho", () => {
const r = criarRegulador(2);
janelas(r, 30, 25, 1);
janelas(r, 30, 25, 1);
expect(r.descidasSemGanho).toBe(1);
expect(janelas(r, 25, 20, 1)).toBe(3);
expect(r.descidasSemGanho).toBe(0);
});
it("descida sem ganho que não desce de novo não trava, e a volta não passa do topo", () => {
const r = criarRegulador(2);
janelas(r, 21, 3, 1);
// 20 ms: sem ganho, mas dentro do limite; sobe de volta depois da folga
expect(janelas(r, 20, 3, 5)).toBe(0);
expect(r.descidasSemGanho).toBe(1);
janelas(r, 21, 3, 1);
expect(janelas(r, 21, 3, 1)).toBe(0);
expect(r.travado).toBe(true);
});
it("não passa do último degrau", () => {
const r = reguladorEm(ULTIMO);
expect(janelas(r, 30, 25, 5)).toBe(ULTIMO);
});
it("janela mediana zera a folga acumulada", () => {
const r = reguladorEm(1);
janelas(r, 8, 2, 4);
janelas(r, 16, 8, 1);
expect(janelas(r, 8, 2, 4)).toBe(1);
});
it("descer logo depois de subir trava a subida: depois disso 20 s de folga não sobem", () => {
const r = reguladorEm(2);
expect(janelas(r, 8, 2, 5)).toBe(1);
expect(janelas(r, 30, 25, 1)).toBe(2);
expect(janelas(r, 8, 2, 10)).toBe(2);
expect(r.subidaTravada).toBe(true);
});
it("descer muito depois de subir não trava", () => {
const r = reguladorEm(2);
expect(janelas(r, 8, 2, 5)).toBe(1);
janelas(r, 16, 8, 15);
expect(janelas(r, 30, 25, 1)).toBe(2);
expect(janelas(r, 8, 2, 5)).toBe(1);
});
it("quadro de 300 ms é ignorado", () => {
const r = reguladorEm(1);
for (let i = 0; i < 20; i++) expect(amostrar(r, 300, 3)).toBe(1);
expect(r.intervalos).toHaveLength(0);
});
it("o p95 decide, não a média", () => {
const r = criarRegulador(2);
let nivel = 0;
for (let g = 0; g < 16; g++) {
for (let i = 0; i < 9; i++) nivel = amostrar(r, 10, 2);
nivel = amostrar(r, 40, 2);
}
expect(nivel).toBe(1);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Regulador de qualidade: numa janela, mede o p95 do intervalo entre quadros e o do trabalho de JS de cada quadro,
* e sobe/desce degraus de `NIVEIS`. Sem estado no módulo.
*/
import { NIVEIS, REGULADOR } from "./valores";
export type Regulador = {
nivel: number;
/** Primeiro nível cujo `pixelRatio` cabe no do aparelho; o regulador nunca sobe acima dele. */
topo: number;
/** Intervalos entre quadros da janela atual, em ms. */
intervalos: number[];
/** Tempo de JS de cada quadro da janela atual (lógica + chamada de render), em ms. */
trabalhos: number[];
/** Soma dos intervalos da janela atual, em ms. */
somaDaJanela: number;
/** Tempo seguido de janelas com folga, em ms. */
folga: number;
/** Soma dos intervalos aceitos desde o início, em ms. */
tempo: number;
/** Valor de `tempo` na última subida; null se nunca subiu. */
tempoDaSubida: number | null;
/** Desceu logo depois de subir: não sobe mais. */
subidaTravada: boolean;
/** Descer não adiantou duas vezes seguidas: o limite é da tela, não da nossa carga. Não muda mais de nível. */
travado: boolean;
/** p95 do intervalo na janela que acabou de descer; null se a janela anterior não desceu. */
p95DaDescida: number | null;
descidasSemGanho: number;
};
const ULTIMO = NIVEIS.length - 1;
export function criarRegulador(pixelRatio: number): Regulador {
// abaixo do menor `pixelRatio` (zoom de afastar), vale o primeiro degrau com o menor
const tela = Math.max(pixelRatio, NIVEIS[ULTIMO].pixelRatio);
const topo = NIVEIS.findIndex((n) => n.pixelRatio <= tela);
return {
nivel: topo,
topo,
intervalos: [],
trabalhos: [],
somaDaJanela: 0,
folga: 0,
tempo: 0,
tempoDaSubida: null,
subidaTravada: false,
travado: false,
p95DaDescida: null,
descidasSemGanho: 0,
};
}
export function nivelAtual(r: Regulador): (typeof NIVEIS)[number] {
return NIVEIS[r.nivel];
}
/** Ordena a própria janela: ela é esvaziada logo depois. */
function p95(quadros: number[]): number {
quadros.sort((a, b) => a - b);
return quadros[Math.ceil(0.95 * quadros.length) - 1];
}
function descer(r: Regulador, intervalo: number): void {
r.folga = 0;
if (r.nivel >= ULTIMO) return;
r.nivel += 1;
r.p95DaDescida = intervalo;
const subiuHaPouco =
r.tempoDaSubida !== null && r.tempo - r.tempoDaSubida < REGULADOR.trava;
if (subiuHaPouco) r.subidaTravada = true;
}
function acumularFolga(r: Regulador, duracao: number): void {
r.folga += duracao;
if (r.folga < REGULADOR.subida) return;
if (!r.subidaTravada && r.nivel > r.topo) {
r.nivel -= 1;
r.tempoDaSubida = r.tempo;
}
r.folga = 0;
}
/**
* Na janela seguinte a uma descida: se o intervalo não melhorou `REGULADOR.ganho`, conta uma descida sem ganho; na
* segunda seguida, volta os degraus e trava. Devolve true se travou.
*/
function conferirGanho(r: Regulador, intervalo: number): boolean {
const antes = r.p95DaDescida;
r.p95DaDescida = null;
if (antes === null) return false;
if (intervalo <= antes * (1 - REGULADOR.ganho)) {
r.descidasSemGanho = 0;
return false;
}
r.descidasSemGanho += 1;
if (r.descidasSemGanho < REGULADOR.descidasSemGanho) return false;
r.nivel = Math.max(r.topo, r.nivel - r.descidasSemGanho);
r.travado = true;
return true;
}
function fecharJanela(r: Regulador): void {
const intervalo = p95(r.intervalos);
const trabalho = p95(r.trabalhos);
const duracao = r.somaDaJanela;
r.intervalos.length = 0;
r.trabalhos.length = 0;
r.somaDaJanela = 0;
if (conferirGanho(r, intervalo)) return;
if (intervalo > REGULADOR.limite) descer(r, intervalo);
else if (trabalho < REGULADOR.folgaDeTrabalho) acumularFolga(r, duracao);
else r.folga = 0;
}
export function amostrar(
r: Regulador,
intervaloMs: number,
trabalhoMs: number,
): number {
if (r.travado || intervaloMs > REGULADOR.ignorar) return r.nivel;
r.intervalos.push(intervaloMs);
r.trabalhos.push(trabalhoMs);
r.somaDaJanela += intervaloMs;
r.tempo += intervaloMs;
if (r.somaDaJanela >= REGULADOR.janela) fecharJanela(r);
return r.nivel;
}
+66
View File
@@ -0,0 +1,66 @@
import { semear, sortear, sortearInteiro } from "./rng";
function sequencia(semente: number, n: number): number[] {
const p = { rng: semear(semente) };
return Array.from({ length: n }, () => sortear(p));
}
describe("semear", () => {
it("converte a semente em uint32", () => {
expect(semear(5)).toBe(5);
expect(semear(2 ** 32 + 7)).toBe(7);
expect(semear(-1)).toBe(0xffffffff);
});
it("troca a semente 0 por 0x9e3779b9", () => {
expect(semear(0)).toBe(0x9e3779b9);
});
});
describe("sortear", () => {
it("dá a mesma sequência para a mesma semente", () => {
expect(sequencia(42, 20)).toEqual(sequencia(42, 20));
});
it("dá sequências diferentes para sementes diferentes", () => {
expect(sequencia(1, 20)).not.toEqual(sequencia(2, 20));
});
it("mantém 1000 sorteios em [0, 1)", () => {
for (const v of sequencia(7, 1000)) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it("funciona com a semente 0", () => {
const valores = sequencia(0, 10);
expect(new Set(valores).size).toBe(10);
});
it("avança o estado de p.rng", () => {
const p = { rng: semear(9) };
const antes = p.rng;
sortear(p);
expect(p.rng).not.toBe(antes);
});
});
describe("sortearInteiro", () => {
it("devolve inteiros de 0 a n-1 e usa todos os valores", () => {
const p = { rng: semear(3) };
const vistos = new Set<number>();
for (let i = 0; i < 500; i++) {
const v = sortearInteiro(p, 6);
expect(Number.isInteger(v)).toBe(true);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(6);
vistos.add(v);
}
expect(vistos.size).toBe(6);
});
it("devolve sempre 0 quando n é 1", () => {
expect(sortearInteiro({ rng: semear(1) }, 1)).toBe(0);
});
});
+21
View File
@@ -0,0 +1,21 @@
/** Sorteio determinístico (mulberry32). Único ponto de aleatoriedade da lógica do jogo. */
/** Estado inicial uint32 a partir de uma semente; 0 vira 0x9e3779b9. */
export function semear(semente: number): number {
const estado = semente >>> 0;
return estado === 0 ? 0x9e3779b9 : estado;
}
/** Avança `p.rng` e devolve um número em [0, 1). */
export function sortear(p: { rng: number }): number {
p.rng = (p.rng + 0x6d2b79f5) >>> 0;
let t = p.rng;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
/** Inteiro de 0 a n−1. */
export function sortearInteiro(p: { rng: number }, n: number): number {
return Math.floor(sortear(p) * n);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Contrato da lógica do jogo. Nada aqui conhece three nem DOM.
*
* Plano XZ em metros, Y para cima. `rumo` em radianos, com direção `(sin(rumo), cos(rumo))` em (x, z);
* `inclinacao` positiva olha para cima. O tempo anda em passos fixos de `PASSO` segundos (`valores.ts`).
* A partida é um objeto só, alterado no lugar por `avancar`.
*/
export type Dificuldade = "facil" | "normal" | "dificil";
export type TipoZumbi = "comum" | "rapido" | "blindado" | "chefao";
export type Fase =
| "inicio"
| "horda"
| "pausa"
| "chefao"
| "vitoria"
| "derrota";
/** 0 pistola, 1 automática, 2 faca. */
export type ArmaId = 0 | 1 | 2;
/** O que o jogador (ou o piloto) pede em um passo. */
export type Entrada = {
/** x: direita (+) e esquerda (−); y: frente (+) e trás (−). Cada um de −1 a 1. */
mover: readonly [number, number];
/** Giro pedido neste passo, em radianos: [rumo, inclinação]. */
olhar: readonly [number, number];
atirar: boolean;
arma: ArmaId;
};
export type Jogador = {
x: number;
z: number;
rumo: number;
inclinacao: number;
vida: number;
arma: ArmaId;
/** Balas da automática. A pistola e a faca não gastam. */
municao: number;
/** Passos que faltam para a arma poder agir de novo. */
recarga: number;
};
export type EstadoZumbi = "andando" | "atacando" | "morrendo";
export type Zumbi = {
id: number;
tipo: TipoZumbi;
/** O problema que este zumbi representa (id de `problemas.ts`). */
problemaId: string;
x: number;
z: number;
rumo: number;
vida: number;
estado: EstadoZumbi;
/** Passos no estado atual. */
tempo: number;
};
/** Zumbi da horda que ainda não entrou no mapa, por causa do teto de zumbis à vista. */
export type NaFila = { tipo: TipoZumbi; problemaId: string };
export type Drone = {
id: number;
/** O problema resolvido que virou este drone; o nome dele é a `solucao` do problema. */
problemaId: string;
x: number;
z: number;
rumo: number;
recarga: number;
eliminacoes: number;
};
/** Caixa de munição caída no chão. */
export type Caixa = { id: number; x: number; z: number };
export type TipoItem = "municao" | "kit" | "cafe" | "bateria";
/** Item largado por um zumbi morto, no chão até ser recolhido ou vencer. */
export type Item = {
id: number;
tipo: TipoItem;
x: number;
z: number;
/** Passo em que caiu. */
nasceu: number;
};
export type LinhaFeed = { passo: number; texto: string };
/** Quem atirou ou matou: o jogador ou o drone com este id. */
export type Autor = "jogador" | number;
/** O que aconteceu neste passo e a tela precisa mostrar. A lista é esvaziada no começo de cada `avancar`. */
export type Evento =
| {
tipo: "tiro";
autor: Autor;
arma: ArmaId | "drone";
/** Distância até onde o tiro parou (zumbi, parede ou alcance). */
distancia: number;
}
| {
tipo: "acerto";
zumbiId: number;
cabeca: boolean;
x: number;
y: number;
z: number;
}
| { tipo: "morte"; zumbiId: number; autor: Autor }
/** A mão do zumbi bateu no jogador: é o único momento em que ele tira vida. */
| { tipo: "golpe"; dano: number; zumbiId: number }
/** O golpe chegou ao impacto com o jogador fora do alcance: não tira vida. */
| { tipo: "golpeNoAr"; zumbiId: number }
| { tipo: "itemCaiu"; item: TipoItem; x: number; z: number }
| { tipo: "itemPego"; item: TipoItem }
| { tipo: "caixa"; balas: number }
| { tipo: "resolvido"; problemaId: string; droneId: number }
| { tipo: "fase"; fase: Fase };
export type Mapa = {
largura: number;
altura: number;
/** 1 onde não dá para andar (parede ou mesa). Índice `z * largura + x`. */
solido: Uint8Array;
/** 1 onde o tiro e a visão param (só parede). */
opaco: Uint8Array;
inicio: readonly [number, number];
nascedouros: readonly (readonly [number, number])[];
};
export type Partida = {
passo: number;
/** Estado do sorteio; só `sortear` (`rng.ts`) mexe nele. */
rng: number;
dificuldade: Dificuldade;
/** O problema escolhido pela pessoa: é o chefão. */
problemaId: string;
/** Problema de cada horda, na ordem; o último é o do chefão. */
hordas: readonly string[];
/** Índice em `hordas` da horda atual. */
horda: number;
fase: Fase;
/** Passos na fase atual. */
tempoFase: number;
/** Quantos zumbis podem estar no mapa ao mesmo tempo. */
tetoAVista: number;
mapa: Mapa;
jogador: Jogador;
zumbis: Zumbi[];
fila: NaFila[];
drones: Drone[];
caixas: Caixa[];
feed: LinhaFeed[];
/** Problemas já resolvidos, na ordem. */
resolvidos: string[];
placar: { jogador: number; drones: number };
eventos: Evento[];
proximoId: number;
};
+205
View File
@@ -0,0 +1,205 @@
/** Números do jogo em um lugar só. São valores iniciais: mudam jogando, nunca espalhados pelo código. */
import GOLPE from "./golpe.json";
import type { Dificuldade, TipoItem, TipoZumbi } from "./tipos";
/** Duração de um passo da lógica, em segundos. */
export const PASSO = 1 / 60;
export const PASSOS_POR_SEGUNDO = 60;
export const JOGADOR = {
vida: 100,
/** m/s */
velocidade: 4.2,
raio: 0.35,
/** Altura dos olhos, de onde sai o tiro. */
olhos: 1.6,
/** Limite da inclinação, para cima e para baixo. */
inclinacaoMax: 1.2,
/** Vida devolvida quando uma horda cai. */
curaPorHorda: 25,
} as const;
export const ZUMBIS: Record<
TipoZumbi,
{
vida: number;
velocidade: number;
dano: number;
raio: number;
altura: number;
}
> = {
comum: { vida: 100, velocidade: 1.2, dano: 10, raio: 0.35, altura: 1.8 },
rapido: { vida: 50, velocidade: 2.4, dano: 6, raio: 0.3, altura: 1.7 },
blindado: { vida: 250, velocidade: 0.9, dano: 15, raio: 0.4, altura: 1.9 },
chefao: { vida: 1500, velocidade: 1.0, dano: 25, raio: 0.6, altura: 2.4 },
};
/** Passos de um golpe inteiro (preparo, impacto e volta): 0,8 s. */
const PASSOS_DO_GOLPE = 48;
export const ATAQUE_DO_ZUMBI = {
/** Distância entre as bordas do zumbi e do jogador para o ataque começar. */
alcance: 1.0,
/** Folga máxima, no passo do impacto, para o golpe acertar. Acima disso o golpe pega o ar. */
alcanceDoGolpe: 1.4,
/** Passos entre um golpe e o próximo. Etapa 2: passa a `PASSOS_DO_GOLPE`. */
intervalo: 60,
/**
* Passo do ciclo em que a mão bate. Vem do clipe `atacar` medido no Blender
* (`golpe.json`, gravado por `3d/jogo.py`): a tela toca o clipe no tempo do ciclo,
* então o impacto do clipe e o da lógica caem no mesmo passo.
*/
passoDoGolpe: Math.round((GOLPE.impacto / GOLPE.duracao) * PASSOS_DO_GOLPE),
/** Passos da animação de morte, até o zumbi sair da lista. */
passosMorrendo: 90,
} as const;
/** Acima desta fração da altura do zumbi o acerto é na cabeça e dobra o dano. */
export const FRACAO_DA_CABECA = 0.86;
export const MULTIPLICADOR_DA_CABECA = 2;
export const ARMAS = [
{ nome: "Pistola", dano: 34, intervalo: 20, alcance: 40, gasta: false },
{ nome: "Automática", dano: 20, intervalo: 6, alcance: 40, gasta: true },
{ nome: "Faca", dano: 100, intervalo: 30, alcance: 1.5, gasta: false },
] as const;
/** Meio ângulo do arco da faca, em radianos. */
export const ARCO_DA_FACA = Math.PI / 4;
export const MUNICAO = {
inicial: 90,
porCaixa: 30,
/** Chance de um zumbi morto largar uma caixa. */
chanceDeCaixa: 0.25,
/** Distância para o jogador recolher a caixa. */
raioDeColeta: 0.9,
} as const;
/** O que os zumbis largam ao morrer. */
export const ITENS = {
/** Chance de um zumbi morto largar um item. O chefão não larga. */
chance: 0.3,
/** Peso de cada tipo no sorteio. */
pesos: { municao: 45, kit: 25, cafe: 15, bateria: 15 } as Record<
TipoItem,
number
>,
/** Abaixo desta vida o peso do kit dobra. */
vidaBaixa: 40,
/** Itens no chão ao mesmo tempo; com o teto cheio, o mais antigo some. */
teto: 10,
/** Passos até o item sumir (20 s). */
duracao: 1200,
/** Distância para o jogador recolher. */
raioDeColeta: 0.9,
/** Balas da automática por item de munição. */
balas: 30,
/** Vida devolvida pelo kit. */
vida: 25,
/** Café: fator da velocidade de andar e passos que dura (8 s). */
cafe: { fator: 1.3, duracao: 480 },
/** Bateria: fator da cadência dos drones e passos que dura (10 s). */
bateria: { fator: 2, duracao: 600 },
} as const;
export const DRONE = {
dano: 25,
intervalo: 30,
alcance: 15,
/** m/s */
velocidade: 5,
/** Distância que o drone mantém do jogador. */
distancia: 2,
} as const;
export const HORDAS = {
/** Zumbis das três hordas, antes do multiplicador da dificuldade. */
tamanhos: [12, 16, 20],
/** Comuns que acompanham o chefão. */
escoltaDoChefao: 6,
/** Passos da pausa entre hordas. */
pausa: 300,
/** Passos da fase "inicio", antes da primeira horda. */
preparo: 120,
/** Passos entre dois nascimentos, por horda (a 4ª é a do chefão). */
nascerACada: [90, 50, 30, 30],
/** Fator da velocidade dos zumbis, por horda. */
velocidade: [0.8, 1, 1.15, 1.15],
} as const;
/** Quantas linhas o feed guarda. */
export const LINHAS_DO_FEED = 5;
export const TETO_A_VISTA = 16;
export const DIFICULDADES: Record<
Dificuldade,
{ velocidade: number; dano: number; quantidade: number }
> = {
facil: { velocidade: 0.6, dano: 0.4, quantidade: 0.7 },
normal: { velocidade: 1, dano: 1, quantidade: 1 },
dificil: { velocidade: 1.3, dano: 1.5, quantidade: 1.3 },
};
/**
* O escritório, em células de 1 m. `#` parede, `M` mesa (não dá para andar, o tiro passa),
* `J` onde o jogador começa, `N` onde os zumbis nascem, `.` chão.
*/
export const ESCRITORIO = [
"############################",
"#N.........#......#.......N#",
"#..MM..MM..#......#..MM....#",
"#..MM..MM.........#..MM....#",
"#..........#...............#",
"#..MM..MM..#......#..MM....#",
"#..MM..MM..###..###........#",
"#..........................#",
"#............J.............#",
"#..........................#",
"#..MM..MM..###..###..MM....#",
"#..MM..MM..#......#..MM....#",
"#..........#......#........#",
"#..MM..MM.........#..MM....#",
"#..MM..MM..#...............#",
"#N.........#......#.......N#",
"############################",
] as const;
/** Degraus de qualidade da tela, do melhor para o mais leve. Desce na ordem: resolução, sombras, zumbis à vista. */
export const NIVEIS = [
{ pixelRatio: 2, sombras: true, teto: 16 },
{ pixelRatio: 1.5, sombras: true, teto: 16 },
{ pixelRatio: 1, sombras: true, teto: 16 },
{ pixelRatio: 1, sombras: false, teto: 16 },
{ pixelRatio: 1, sombras: false, teto: 12 },
{ pixelRatio: 1, sombras: false, teto: 8 },
] as const;
/** Tempos em milissegundos. */
export const REGULADOR = {
/** Tamanho da janela em que o p95 do intervalo entre quadros e o do trabalho são medidos. */
janela: 2000,
/** p95 do intervalo acima disto na janela: desce um degrau. */
limite: 20,
/** Janela sem descida e com p95 do trabalho de JS abaixo disto conta como folga. */
folgaDeTrabalho: 6,
/** Tempo seguido de folga para subir um degrau. */
subida: 10000,
/** Quadro mais longo que isto é pausa da aba, não lentidão: não entra na conta. */
ignorar: 250,
/** Se descer de novo antes disto depois de subir, não sobe mais. */
trava: 20000,
/** Fração mínima em que o p95 do intervalo cai na janela seguinte a uma descida para ela contar como ganho. */
ganho: 0.1,
/** Descidas seguidas sem ganho que fazem o regulador voltar esses degraus e travar. */
descidasSemGanho: 2,
} as const;
/**
* Raio máximo usado para andar e desviar de parede. O chefão é mais largo que meia célula (0,6 m): com o raio inteiro
* ele nasce encostado na parede e não sai do lugar. Para levar tiro, vale o raio do tipo.
*/
export const RAIO_DE_PASSAGEM = 0.45;
+459
View File
@@ -0,0 +1,459 @@
import { campoDeFluxo, lerMapa } from "./mapa";
import { sortear } from "./rng";
import type { Partida, Zumbi } from "./tipos";
import { ATAQUE_DO_ZUMBI, MUNICAO } from "./valores";
import { avancarZumbis, ferir, nascer } from "./zumbis";
const SALA = [
"##############",
"#J...........#",
"#.....N......#",
"##############",
];
const DOIS_NASCEDOUROS_LONGE = [
"################",
"#JN.........N.N#",
"#..............#",
"################",
];
const PAREDE_NO_MEIO = ["#######", "#J#..N#", "#.#...#", "#.....#", "#######"];
const BOLSO_FECHADO = ["#######", "#J#..N#", "#.#...#", "#######"];
/** Mesa entre o zumbi e o jogador: o tiro passa, o zumbi não. */
const MESA_NO_CAMINHO = [
"#########",
"#.......#",
"#J..M..N#",
"#.......#",
"#########",
];
/** Corredor com parede em cima de x 4 a 6; o jogador fica depois da curva, fora da vista. */
const QUINA = [
"#########",
"#...##..#",
"#......N#",
"#.#######",
"#J#######",
"#########",
];
/** Vão de uma célula entre duas mesas (x 4): cabe um zumbi por vez. */
const VAO_ENTRE_MESAS = [
"#########",
"#...M...#",
"#J.....N#",
"#...M...#",
"#########",
];
function partida(linhas: string[], extra: Partial<Partida> = {}): Partida {
const mapa = lerMapa(linhas);
return {
passo: 0,
rng: 1,
dificuldade: "normal",
problemaId: "p",
hordas: ["h"],
horda: 0,
fase: "horda",
tempoFase: 0,
tetoAVista: 16,
mapa,
jogador: {
x: mapa.inicio[0],
z: mapa.inicio[1],
rumo: 0,
inclinacao: 0,
vida: 100,
arma: 0,
municao: 90,
recarga: 0,
},
zumbis: [],
fila: [],
drones: [],
caixas: [],
feed: [],
resolvidos: [],
placar: { jogador: 0, drones: 0 },
eventos: [],
proximoId: 1,
...extra,
};
}
function zumbi(extra: Partial<Zumbi> = {}): Zumbi {
return {
id: 1,
tipo: "comum",
problemaId: "p",
x: 8.5,
z: 1.5,
rumo: 0,
vida: 100,
estado: "andando",
tempo: 0,
...extra,
};
}
function avancar(p: Partida): void {
avancarZumbis(p, campoDeFluxo(p.mapa, p.jogador.x, p.jogador.z));
}
/** Uma semente de `p.rng` cujo próximo sorteio fica abaixo (ou não) da chance de caixa. */
function sementeComSorteio(abaixo: boolean): number {
for (let s = 1; ; s++) {
if (sortear({ rng: s }) < MUNICAO.chanceDeCaixa === abaixo) return s;
}
}
describe("nascer", () => {
it("não age fora do passo múltiplo de 20", () => {
const p = partida(SALA, {
passo: 21,
fila: [{ tipo: "comum", problemaId: "a" }],
});
nascer(p);
expect(p.zumbis).toHaveLength(0);
expect(p.fila).toHaveLength(1);
});
it("não age com a fila vazia", () => {
const p = partida(SALA, { passo: 20 });
nascer(p);
expect(p.zumbis).toHaveLength(0);
expect(p.proximoId).toBe(1);
});
it("respeita o teto de zumbis à vista, sem contar os morrendo", () => {
const p = partida(SALA, {
passo: 20,
tetoAVista: 1,
zumbis: [zumbi({ id: 1 })],
fila: [{ tipo: "comum", problemaId: "a" }],
});
nascer(p);
expect(p.zumbis).toHaveLength(1);
p.zumbis[0].estado = "morrendo";
nascer(p);
expect(p.zumbis).toHaveLength(2);
expect(p.fila).toHaveLength(0);
});
it("cria o primeiro da fila com os valores ajustados, virado para o jogador", () => {
const p = partida(SALA, {
passo: 40,
dificuldade: "dificil",
proximoId: 5,
fila: [
{ tipo: "blindado", problemaId: "a" },
{ tipo: "comum", problemaId: "b" },
],
});
nascer(p);
expect(p.fila).toEqual([{ tipo: "comum", problemaId: "b" }]);
expect(p.proximoId).toBe(6);
const z = p.zumbis[0];
expect(z).toMatchObject({
id: 5,
tipo: "blindado",
problemaId: "a",
vida: 250,
estado: "andando",
tempo: 0,
});
expect([z.x, z.z]).toEqual([6.5, 2.5]);
expect(z.rumo).toBeCloseTo(Math.atan2(1.5 - 6.5, 1.5 - 2.5));
});
it("escolhe só nascedouros a 8 m ou mais, pelo índice id % candidatos", () => {
const fila = [{ tipo: "comum" as const, problemaId: "a" }];
const a = partida(DOIS_NASCEDOUROS_LONGE, {
passo: 0,
proximoId: 4,
fila: [...fila],
});
const b = partida(DOIS_NASCEDOUROS_LONGE, {
passo: 0,
proximoId: 5,
fila: [...fila],
});
nascer(a);
nascer(b);
expect(a.zumbis[0].x).toBe(12.5);
expect(b.zumbis[0].x).toBe(14.5);
});
it("usa todos os nascedouros quando nenhum está longe", () => {
const p = partida(["#####", "#JN.#", "#####"], {
fila: [{ tipo: "comum", problemaId: "a" }],
});
nascer(p);
expect(p.zumbis[0].x).toBe(2.5);
});
});
describe("ferir", () => {
it("ignora zumbi que já está morrendo", () => {
const p = partida(SALA);
const z = zumbi({ estado: "morrendo", vida: 0 });
expect(ferir(p, z, 50, "jogador")).toBe(false);
expect(z.vida).toBe(0);
expect(p.placar.jogador).toBe(0);
});
it("só desconta a vida quando não mata", () => {
const p = partida(SALA);
const z = zumbi();
expect(ferir(p, z, 30, "jogador")).toBe(false);
expect(z.vida).toBe(70);
expect(z.estado).toBe("andando");
expect(p.eventos).toEqual([]);
});
it("mata para o jogador: placar, evento e estado", () => {
const p = partida(SALA, { rng: sementeComSorteio(false), proximoId: 9 });
const z = zumbi({ id: 3, tempo: 12 });
expect(ferir(p, z, 100, "jogador")).toBe(true);
expect(z.estado).toBe("morrendo");
expect(z.tempo).toBe(0);
expect(p.placar).toEqual({ jogador: 1, drones: 0 });
expect(p.eventos).toEqual([
{ tipo: "morte", zumbiId: 3, autor: "jogador" },
]);
expect(p.caixas).toEqual([]);
expect(p.proximoId).toBe(9);
});
it("larga uma caixa quando o sorteio fica abaixo da chance", () => {
const p = partida(SALA, { rng: sementeComSorteio(true), proximoId: 9 });
ferir(p, zumbi({ x: 4.5, z: 2.5 }), 500, "jogador");
expect(p.caixas).toEqual([{ id: 9, x: 4.5, z: 2.5 }]);
expect(p.proximoId).toBe(10);
});
it("morte por drone soma em placar.drones e nas eliminações dele", () => {
const p = partida(SALA, {
drones: [
{
id: 7,
problemaId: "a",
x: 0,
z: 0,
rumo: 0,
recarga: 0,
eliminacoes: 0,
},
],
});
ferir(p, zumbi({ id: 2 }), 100, 7);
expect(p.placar).toEqual({ jogador: 0, drones: 1 });
expect(p.drones[0].eliminacoes).toBe(1);
expect(p.eventos).toEqual([{ tipo: "morte", zumbiId: 2, autor: 7 }]);
});
it("morte por drone que não existe só soma no placar", () => {
const p = partida(SALA);
ferir(p, zumbi(), 100, 99);
expect(p.placar.drones).toBe(1);
});
});
describe("avancarZumbis", () => {
it("remove o morrendo só ao completar a animação", () => {
const passos = ATAQUE_DO_ZUMBI.passosMorrendo;
const p = partida(SALA, {
zumbis: [
zumbi({ id: 1, estado: "morrendo", tempo: passos - 2 }),
zumbi({ id: 2, estado: "morrendo", tempo: passos - 1 }),
],
});
avancar(p);
expect(p.zumbis.map((z) => z.id)).toEqual([1]);
expect(p.zumbis[0].tempo).toBe(passos - 1);
});
it("anda em linha reta quando vê o jogador", () => {
const p = partida(SALA, { zumbis: [zumbi({ tempo: 3 })] });
avancar(p);
const z = p.zumbis[0];
expect(z.x).toBeCloseTo(8.5 - 1.2 / 60);
expect(z.z).toBeCloseTo(1.5);
expect(z.rumo).toBeCloseTo(-Math.PI / 2);
expect(z.estado).toBe("andando");
expect(z.tempo).toBe(4);
});
it("usa a velocidade ajustada pela dificuldade", () => {
const p = partida(SALA, {
dificuldade: "dificil",
zumbis: [zumbi()],
});
avancar(p);
expect(p.zumbis[0].x).toBeCloseTo(8.5 - (1.2 * 1.3) / 60);
});
it("zera o tempo ao voltar a andar", () => {
const p = partida(SALA, {
zumbis: [zumbi({ estado: "atacando", tempo: 40 })],
});
avancar(p);
expect(p.zumbis[0].estado).toBe("andando");
expect(p.zumbis[0].tempo).toBe(0);
});
it("sem caminho livre, contorna a parede pelo campo de fluxo", () => {
const p = partida(PAREDE_NO_MEIO, {
zumbis: [zumbi({ x: 3.5, z: 1.5 })],
});
avancar(p);
const z = p.zumbis[0];
expect(z.x).toBeCloseTo(3.5);
expect(z.z).toBeGreaterThan(1.5);
expect(z.rumo).toBeCloseTo(0);
});
it("sem caminho nem visão, fica parado e mantém o rumo", () => {
const p = partida(BOLSO_FECHADO, {
zumbis: [zumbi({ x: 4.5, z: 1.5, rumo: 1.25 })],
});
avancar(p);
const z = p.zumbis[0];
expect([z.x, z.z, z.rumo]).toEqual([4.5, 1.5, 1.25]);
});
it("com mesa entre ele e o jogador, contorna a mesa e chega", () => {
const p = partida(MESA_NO_CAMINHO, {
zumbis: [zumbi({ x: 7.5, z: 2.5 })],
});
for (let i = 0; i < 600; i++) avancar(p);
expect(p.zumbis[0].estado).toBe("atacando");
});
it("encostado numa quina, não fica parado e chega", () => {
const p = partida(QUINA, { zumbis: [zumbi({ x: 6.36, z: 2.0 })] });
avancar(p);
expect(p.zumbis[0].z).toBeGreaterThan(2.0);
for (let i = 0; i < 600; i++) avancar(p);
expect(p.zumbis[0].estado).toBe("atacando");
});
it("ataca ao chegar no alcance, virado para o jogador, sem golpear de cara", () => {
const p = partida(SALA, {
zumbis: [zumbi({ x: 2.5, z: 1.5, tempo: 7 })],
});
avancar(p);
const z = p.zumbis[0];
expect(z.estado).toBe("atacando");
expect(z.tempo).toBe(0);
expect(z.rumo).toBeCloseTo(-Math.PI / 2);
expect([z.x, z.z]).toEqual([2.5, 1.5]);
expect(p.eventos).toEqual([]);
});
it("golpeia só no passo certo do intervalo", () => {
const p = partida(SALA, {
dificuldade: "dificil",
zumbis: [zumbi({ x: 2.5, z: 1.5, estado: "atacando", tempo: 17 })],
});
avancar(p);
avancar(p);
expect(p.eventos).toEqual([]);
avancar(p);
expect(p.zumbis[0].tempo).toBe(20);
expect(p.eventos).toEqual([
{ tipo: "golpe", dano: 15, zumbiId: p.zumbis[0].id },
]);
expect(p.jogador.vida).toBe(85);
for (let i = 0; i < ATAQUE_DO_ZUMBI.intervalo - 1; i++) avancar(p);
expect(p.eventos).toHaveLength(1);
avancar(p);
expect(p.eventos).toHaveLength(2);
});
it("a vida do jogador não fica negativa", () => {
const p = partida(SALA, {
zumbis: [zumbi({ x: 2.5, z: 1.5, estado: "atacando", tempo: 19 })],
});
p.jogador.vida = 4;
avancar(p);
expect(p.jogador.vida).toBe(0);
});
it("dois zumbis sobrepostos terminam afastados", () => {
const p = partida(SALA, {
zumbis: [
zumbi({ id: 1, x: 6.5, z: 1.5 }),
zumbi({ id: 2, x: 6.8, z: 1.5 }),
],
});
avancar(p);
const [a, b] = p.zumbis;
expect(Math.hypot(b.x - a.x, b.z - a.z)).toBeGreaterThanOrEqual(0.7 - 1e-9);
});
it("na sobreposição só o mais longe do jogador recua; o da frente segue o passo normal", () => {
for (const perto of [0, 1]) {
const xs = perto === 0 ? [6.5, 6.8] : [6.8, 6.5];
const p = partida(SALA, {
zumbis: xs.map((x, i) => zumbi({ id: i + 1, x, z: 1.5 })),
});
avancar(p);
expect(p.zumbis[perto].x).toBeCloseTo(6.5 - 1.2 / 60);
expect(p.zumbis[1 - perto].x).toBeCloseTo(6.5 - 1.2 / 60 + 0.7);
}
});
it("par exatamente no mesmo ponto é afastado no eixo x", () => {
const p = partida(SALA, {
zumbis: [
zumbi({ id: 1, x: 6.5, z: 1.5 }),
zumbi({ id: 2, x: 6.5, z: 1.5 }),
],
});
avancar(p);
const [a, b] = p.zumbis;
expect(b.x - a.x).toBeCloseTo(0.7);
expect(b.z).toBeCloseTo(a.z);
});
it("dois zumbis lado a lado na boca de um vão estreito passam os dois, um por vez", () => {
const p = partida(VAO_ENTRE_MESAS, {
zumbis: [
zumbi({ id: 1, x: 5.4, z: 2.15 }),
zumbi({ id: 2, x: 5.4, z: 2.85 }),
],
});
for (let i = 0; i < 300; i++) avancar(p);
expect(p.zumbis.map((z) => z.x < 4)).toEqual([true, true]);
});
it("não separa quem está morrendo nem quem já está afastado", () => {
const p = partida(SALA, {
zumbis: [
zumbi({ id: 1, x: 6.5, z: 1.5, estado: "morrendo" }),
zumbi({ id: 2, x: 6.5, z: 1.5, estado: "morrendo" }),
zumbi({ id: 3, x: 9.5, z: 1.5 }),
zumbi({ id: 4, x: 11.5, z: 1.5 }),
],
});
avancar(p);
expect([p.zumbis[0].x, p.zumbis[1].x]).toEqual([6.5, 6.5]);
expect(p.zumbis[2].x).toBeCloseTo(9.5 - 1.2 / 60);
expect(p.zumbis[3].x).toBeCloseTo(11.5 - 1.2 / 60);
});
it("chefão no centro de uma célula cercada de parede por três lados anda para o lado livre", () => {
const p = partida(["#######", "#N...J#", "#######"], {
zumbis: [zumbi({ tipo: "chefao", x: 1.5, z: 1.5 })],
});
avancar(p);
expect(p.zumbis[0].x).toBeGreaterThan(1.5);
expect(p.zumbis[0].z).toBeCloseTo(1.5);
});
});
+199
View File
@@ -0,0 +1,199 @@
/** Zumbis: nascer, ferir, andar, atacar e se afastar uns dos outros. Altera a `Partida` no lugar. */
import { ajustar } from "./dificuldade";
import { caminhoLivre, direcaoNoCampo, moverComColisao } from "./mapa";
import { sortear } from "./rng";
import type { Autor, Partida, Zumbi } from "./tipos";
import {
ATAQUE_DO_ZUMBI,
JOGADOR,
MUNICAO,
PASSO,
RAIO_DE_PASSAGEM,
ZUMBIS,
} from "./valores";
const INTERVALO_DE_NASCER = 20;
const DISTANCIA_MINIMA_DO_NASCEDOURO = 8;
/** Passo (dentro do intervalo) em que o golpe acontece. */
const PASSO_DO_GOLPE = 20;
/** Raio usado contra o mapa: corpo grande não pode ficar maior que a passagem. */
function raioNoMapa(zumbi: Zumbi): number {
return Math.min(ZUMBIS[zumbi.tipo].raio, RAIO_DE_PASSAGEM);
}
function distanciaAoJogador(p: Partida, x: number, z: number): number {
return Math.hypot(p.jogador.x - x, p.jogador.z - z);
}
function nascedouroPara(p: Partida, id: number): readonly [number, number] {
const todos = p.mapa.nascedouros;
const longe = todos.filter(
([x, z]) => distanciaAoJogador(p, x, z) >= DISTANCIA_MINIMA_DO_NASCEDOURO,
);
const lista = longe.length > 0 ? longe : todos;
return lista[id % lista.length];
}
export function nascer(p: Partida): void {
const proximo = p.fila[0];
const aVista = p.zumbis.filter((z) => z.estado !== "morrendo").length;
if (
p.passo % INTERVALO_DE_NASCER !== 0 ||
!proximo ||
aVista >= p.tetoAVista
) {
return;
}
p.fila.shift();
const id = p.proximoId++;
const [x, z] = nascedouroPara(p, id);
p.zumbis.push({
id,
tipo: proximo.tipo,
problemaId: proximo.problemaId,
x,
z,
rumo: Math.atan2(p.jogador.x - x, p.jogador.z - z),
vida: ajustar(proximo.tipo, p.dificuldade).vida,
estado: "andando",
tempo: 0,
});
}
function pontuar(p: Partida, autor: Autor): void {
if (autor === "jogador") {
p.placar.jogador++;
return;
}
p.placar.drones++;
const drone = p.drones.find((d) => d.id === autor);
if (drone) drone.eliminacoes++;
}
function registrarMorte(p: Partida, zumbi: Zumbi, autor: Autor): void {
zumbi.estado = "morrendo";
zumbi.tempo = 0;
pontuar(p, autor);
p.eventos.push({ tipo: "morte", zumbiId: zumbi.id, autor });
if (sortear(p) < MUNICAO.chanceDeCaixa) {
p.caixas.push({ id: p.proximoId++, x: zumbi.x, z: zumbi.z });
}
}
/** Devolve true se este golpe matou. */
export function ferir(
p: Partida,
zumbi: Zumbi,
dano: number,
autor: Autor,
): boolean {
if (zumbi.estado === "morrendo") return false;
zumbi.vida -= dano;
if (zumbi.vida > 0) return false;
registrarMorte(p, zumbi, autor);
return true;
}
function noAlcance(p: Partida, zumbi: Zumbi): boolean {
const folga =
distanciaAoJogador(p, zumbi.x, zumbi.z) -
ZUMBIS[zumbi.tipo].raio -
JOGADOR.raio;
return folga <= ATAQUE_DO_ZUMBI.alcance;
}
function entrarEm(zumbi: Zumbi, estado: "andando" | "atacando"): void {
if (zumbi.estado === estado) return;
zumbi.estado = estado;
zumbi.tempo = 0;
}
function atacar(p: Partida, zumbi: Zumbi): void {
entrarEm(zumbi, "atacando");
zumbi.rumo = Math.atan2(p.jogador.x - zumbi.x, p.jogador.z - zumbi.z);
if (zumbi.tempo % ATAQUE_DO_ZUMBI.intervalo !== PASSO_DO_GOLPE) return;
const { dano } = ajustar(zumbi.tipo, p.dificuldade);
p.jogador.vida = Math.max(0, p.jogador.vida - dano);
p.eventos.push({ tipo: "golpe", dano, zumbiId: zumbi.id });
}
/** Em linha reta só se o corpo inteiro passa até o jogador; senão segue o campo. */
function direcaoDoZumbi(
p: Partida,
campo: Int16Array,
zumbi: Zumbi,
): [number, number] {
const { x, z } = zumbi;
const raio = raioNoMapa(zumbi);
if (caminhoLivre(p.mapa, x, z, p.jogador.x, p.jogador.z, raio)) {
const d = distanciaAoJogador(p, x, z);
return [(p.jogador.x - x) / d, (p.jogador.z - z) / d];
}
return direcaoNoCampo(p.mapa, campo, x, z);
}
function andar(p: Partida, campo: Int16Array, zumbi: Zumbi): void {
entrarEm(zumbi, "andando");
const [dx, dz] = direcaoDoZumbi(p, campo, zumbi);
if (dx === 0 && dz === 0) return;
const passo = ajustar(zumbi.tipo, p.dificuldade).velocidade * PASSO;
[zumbi.x, zumbi.z] = moverComColisao(
p.mapa,
zumbi.x,
zumbi.z,
dx * passo,
dz * passo,
raioNoMapa(zumbi),
);
zumbi.rumo = Math.atan2(dx, dz);
}
function avancarUm(p: Partida, campo: Int16Array, zumbi: Zumbi): void {
zumbi.tempo++;
if (zumbi.estado === "morrendo") return;
if (noAlcance(p, zumbi)) atacar(p, zumbi);
else andar(p, campo, zumbi);
}
/**
* Só o mais longe do jogador cede, recuando a sobreposição inteira; no mesmo ponto, no eixo x.
* Meio a meio, dois zumbis na boca de um vão de uma célula se empurravam contra as mesas para sempre.
*/
function separarPar(p: Partida, a: Zumbi, b: Zumbi): void {
const d = Math.hypot(b.x - a.x, b.z - a.z);
const sobra = ZUMBIS[a.tipo].raio + ZUMBIS[b.tipo].raio - d;
if (sobra <= 0) return;
const bCede =
distanciaAoJogador(p, a.x, a.z) <= distanciaAoJogador(p, b.x, b.z);
const [fica, cede] = bCede ? [a, b] : [b, a];
const [ux, uz] =
d === 0 ? [1, 0] : [(cede.x - fica.x) / d, (cede.z - fica.z) / d];
[cede.x, cede.z] = moverComColisao(
p.mapa,
cede.x,
cede.z,
ux * sobra,
uz * sobra,
raioNoMapa(cede),
);
}
function separar(p: Partida): void {
const vivos = p.zumbis.filter((z) => z.estado !== "morrendo");
for (let i = 0; i < vivos.length; i++) {
for (let j = i + 1; j < vivos.length; j++) {
separarPar(p, vivos[i], vivos[j]);
}
}
}
export function avancarZumbis(p: Partida, campo: Int16Array): void {
for (const zumbi of p.zumbis) avancarUm(p, campo, zumbi);
const restantes = p.zumbis.filter(
(z) => z.estado !== "morrendo" || z.tempo < ATAQUE_DO_ZUMBI.passosMorrendo,
);
p.zumbis.splice(0, p.zumbis.length, ...restantes);
separar(p);
}
+1
View File
@@ -29,6 +29,7 @@
"format": "biome format --write",
"test": "jest",
"test:watch": "jest --watch",
"test:jogo": "jest lib/jogo --coverage --collectCoverageFrom='lib/jogo/**/*.ts' --collectCoverageFrom='!lib/jogo/tipos.ts' --collectCoverageFrom='!lib/jogo/**/*.test.ts' --coverageThreshold='{\"global\":{\"statements\":100,\"branches\":100,\"functions\":100,\"lines\":100}}'",
"test:e2e": "playwright test",
"check:projects": "node scripts/check-projects.mjs"
},
+110
View File
@@ -0,0 +1,110 @@
[
{
"arquivo": "zumbi.glb",
"origem": "https://opengameart.org/content/male-city-zombie-ready-for-use-in-game-engines",
"autor": "Rikindle3D",
"licenca": "CC0-1.0"
},
{
"arquivo": "roupa-b.jpg",
"origem": "gerada para este jogo",
"autor": "Evandro Fonseca Junior",
"licenca": "propria"
},
{
"arquivo": "roupa-c.jpg",
"origem": "gerada para este jogo",
"autor": "Evandro Fonseca Junior",
"licenca": "propria"
},
{
"arquivo": "capa.jpg",
"origem": "render feito para este jogo a partir de zumbi.glb",
"autor": "Evandro Fonseca Junior",
"licenca": "propria"
},
{
"arquivo": "sons/pistola.mp3",
"origem": "https://opengameart.org/content/gunshots",
"autor": "kurt",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/automatica.mp3",
"origem": "https://opengameart.org/content/the-free-firearm-sound-library",
"autor": "bart",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/faca.mp3",
"origem": "https://kenney.nl/assets/rpg-audio",
"autor": "Kenney",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/caixa.mp3",
"origem": "https://kenney.nl/assets/rpg-audio",
"autor": "Kenney",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/acerto.mp3",
"origem": "https://opengameart.org/content/squish-sounds-effects",
"autor": "EZduzziteh",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/gemido-1.mp3",
"origem": "https://opengameart.org/content/undead-moans",
"autor": "AntumDeluge",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/gemido-2.mp3",
"origem": "https://opengameart.org/content/undead-moans",
"autor": "AntumDeluge",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/golpe.mp3",
"origem": "https://kenney.nl/assets/impact-sounds",
"autor": "Kenney",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/drone.mp3",
"origem": "https://kenney.nl/assets/sci-fi-sounds",
"autor": "Kenney",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/resolvido.mp3",
"origem": "https://kenney.nl/assets/interface-sounds",
"autor": "Kenney",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/dano.mp3",
"origem": "https://opengameart.org/content/pain-sounds-by-emopreben",
"autor": "EmoPreben",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/item.mp3",
"origem": "https://kenney.nl/assets/interface-sounds",
"autor": "Kenney",
"licenca": "CC0-1.0"
},
{
"arquivo": "sons/golpe-no-ar.mp3",
"origem": "https://opengameart.org/content/swish-bamboo-stick-weapon-swhoshes",
"autor": "qubodup",
"licenca": "CC0-1.0"
},
{
"arquivo": "itens.webp",
"origem": "imagens geradas para este jogo (munição, kit médico, café, bateria)",
"autor": "Evandro Fonseca Junior",
"licenca": "propria"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more