feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
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.
No files matched your search
@@ -29,6 +29,7 @@ jobs:
|
|||||||
- run: pnpm lint
|
- run: pnpm lint
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
- run: pnpm test -- --runInBand
|
- run: pnpm test -- --runInBand
|
||||||
|
- run: pnpm test:jogo
|
||||||
- run: pnpm build
|
- run: pnpm build
|
||||||
|
|
||||||
audit:
|
audit:
|
||||||
|
|||||||
@@ -64,3 +64,5 @@ next-env.d.ts
|
|||||||
|
|
||||||
/3d/out/
|
/3d/out/
|
||||||
/3d/__pycache__/
|
/3d/__pycache__/
|
||||||
|
/3d/fontes/
|
||||||
|
/3d/ferramentas/
|
||||||
@@ -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 |
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 803 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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]]()
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 71 KiB |
@@ -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>
|
||||||
|
After Width: | Height: | Size: 468 B |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 522 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 553 KiB |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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})`;
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ describe("sitemap", () => {
|
|||||||
expect(urls.some((url) => /not-found|nada/.test(url))).toBe(false);
|
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", () => {
|
it("gives every route the modification date of its own content, equal on every build", () => {
|
||||||
const day = (value: Date | string | undefined) =>
|
const day = (value: Date | string | undefined) =>
|
||||||
new Date(value ?? "").toISOString().slice(0, 10);
|
new Date(value ?? "").toISOString().slice(0, 10);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"duracao": 1.4,
|
||||||
|
"impacto": 0.833,
|
||||||
|
"mao": "direita",
|
||||||
|
"alcance": 0.79
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"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",
|
"test:e2e": "playwright test",
|
||||||
"check:projects": "node scripts/check-projects.mjs"
|
"check:projects": "node scripts/check-projects.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 32 KiB |