feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s

Etapa 1 inteira (logica com 100% de cobertura, cena 3D, toque, sons, bancada de desempenho, e2e) e a base da etapa 2 (mapa novo no Tiled, texturas, imagens dos itens, sons conferidos, golpe recortado e medido). Pagina /jogo fora do menu e do sitemap ate a medicao no celular.
This commit is contained in:
evandro committed 2026-11-10 19:37:10 -03:00
1 parent c7d09d63d4
commit 67b32fcab3
118 files changed
+16730

No files matched your search

+480
View File
@@ -0,0 +1,480 @@
/**
* O escritório à noite, montado a partir do `Mapa` da lógica (célula de 1 m). Dez chamadas de desenho: piso, forro,
* paredes, pilares, mesas, monitores, telas, painéis do teto, portas dos nascedouros e luzes de emergência.
* Texturas só de `<canvas>`.
*/
import {
BoxGeometry,
type Camera,
CanvasTexture,
Color,
Fog,
HemisphereLight,
InstancedMesh,
type Material,
Matrix4,
Mesh,
MeshBasicMaterial,
MeshStandardMaterial,
Object3D,
PlaneGeometry,
PointLight,
RepeatWrapping,
type Scene,
SpotLight,
SRGBColorSpace,
type Texture,
} from "three";
import type { Mapa } from "@/lib/jogo/tipos";
export type Ambiente = {
definirSombras(ligadas: boolean): void;
/** Pisca o painel com defeito; `segundos` é o relógio da partida. */
atualizar(segundos: number): void;
descartar(): void;
};
const PE_DIREITO = 3;
const ALTURA_DA_MESA = 0.74;
const NOITE = "#06070a";
const LIMA = new Color("#ccff00").multiplyScalar(0.85);
const AZUL_FRIO = new Color("#3d6bff").multiplyScalar(0.55);
const PAINEL_ACESO = new Color("#dfe9ff");
const PAINEL_APAGADO = new Color("#2a2e35");
/** Um painel a cada 4 m nos dois eixos. */
const ESPACO_DOS_PAINEIS = 4;
/** Onde ficam os painéis acesos, em fração do mapa; o último pisca. */
const PAINEIS_ACESOS: readonly (readonly [number, number])[] = [
[0.5, 0.5],
[0.2, 0.2],
[0.8, 0.2],
[0.75, 0.85],
];
/** Porta do nascedouro, com a frente para +z: largura, altura, profundidade e centro (x, y, z). */
const PECAS_DA_PORTA: readonly (readonly [
number,
number,
number,
number,
number,
number,
])[] = [
[0.9, 2.05, 0.03, 0, 1.025, 0.015],
[0.06, 2.14, 0.05, -0.48, 1.07, 0.025],
[0.06, 2.14, 0.05, 0.48, 1.07, 0.025],
[1.02, 0.06, 0.05, 0, 2.11, 0.025],
[0.7, 0.04, 0.04, 0, 1.02, 0.05],
];
/** Folha escura, batente e verga cor de vinho, barra antipânico. */
const CORES_DA_PORTA = [
new Color("#1c1a1c"),
new Color("#4a1414"),
new Color("#4a1414"),
new Color("#4a1414"),
new Color("#8a8f96"),
];
/** Textura feita num `<canvas>`; sem contexto 2D (jsdom) sai em branco. */
export function texturaDeCanvas(
largura: number,
altura: number,
desenhar: (ctx: CanvasRenderingContext2D) => void,
): CanvasTexture {
const canvas = document.createElement("canvas");
canvas.width = largura;
canvas.height = altura;
const ctx = canvas.getContext("2d");
if (ctx) desenhar(ctx);
const textura = new CanvasTexture(canvas);
textura.colorSpace = SRGBColorSpace;
return textura;
}
/** Pontos claros e escuros espalhados, para a superfície não ficar lisa. */
function salpicar(
ctx: CanvasRenderingContext2D,
lado: number,
quantos: number,
semente: number,
) {
let s = semente;
const sortear = () => {
s = (s * 16807) % 2147483647;
return s / 2147483647;
};
for (let i = 0; i < quantos; i++) {
ctx.fillStyle =
sortear() < 0.5 ? "rgba(0,0,0,0.25)" : "rgba(255,255,255,0.05)";
ctx.fillRect(sortear() * lado, sortear() * lado, 2, 2);
}
}
function repetir(textura: Texture, x: number, y: number): Texture {
textura.wrapS = RepeatWrapping;
textura.wrapT = RepeatWrapping;
textura.repeat.set(x, y);
return textura;
}
function texturaDoCarpete(): CanvasTexture {
return texturaDeCanvas(256, 256, (ctx) => {
ctx.fillStyle = "#24262b";
ctx.fillRect(0, 0, 256, 256);
salpicar(ctx, 256, 6000, 7);
ctx.strokeStyle = "rgba(0,0,0,0.35)";
ctx.lineWidth = 2;
ctx.strokeRect(0, 0, 256, 256);
});
}
/** Manchas largas e fracas, para o reboco ter variação sem parecer granulado de perto. */
function manchar(
ctx: CanvasRenderingContext2D,
largura: number,
altura: number,
quantas: number,
semente: number,
) {
let s = semente;
const sortear = () => {
s = (s * 16807) % 2147483647;
return s / 2147483647;
};
for (let i = 0; i < quantas; i++) {
const [cor, alfa] =
sortear() < 0.6 ? ["0,0,0", 0.05] : ["255,255,255", 0.025];
const x = sortear() * largura;
const y = sortear() * altura;
const raio = 20 + sortear() * 70;
// repete dos dois lados para a mancha continuar na célula vizinha sem costura
for (const deslocamento of [-largura, 0, largura]) {
const cx = x + deslocamento;
const borda = ctx.createRadialGradient(cx, y, 0, cx, y, raio);
borda.addColorStop(0, `rgba(${cor},${alfa})`);
borda.addColorStop(1, `rgba(${cor},0)`);
ctx.fillStyle = borda;
ctx.fillRect(cx - raio, y - raio, raio * 2, raio * 2);
}
}
}
/** 1 m × 3 m de parede em 256 × 768 px: o pixel fica quadrado na face. */
function texturaDaParede(): CanvasTexture {
return texturaDeCanvas(256, 768, (ctx) => {
ctx.fillStyle = "#3a3e46";
ctx.fillRect(0, 0, 256, 768);
manchar(ctx, 256, 768, 90, 11);
ctx.fillStyle = "rgba(0,0,0,0.3)";
ctx.fillRect(0, 0, 2, 768);
// rodapé de 10 cm
ctx.fillStyle = "#15171a";
ctx.fillRect(0, 742, 256, 26);
});
}
function texturaDoForro(): CanvasTexture {
return texturaDeCanvas(256, 256, (ctx) => {
ctx.fillStyle = "#30333a";
ctx.fillRect(0, 0, 256, 256);
salpicar(ctx, 256, 1500, 3);
ctx.strokeStyle = "#16181c";
ctx.lineWidth = 4;
ctx.strokeRect(0, 0, 128, 128);
ctx.strokeRect(128, 128, 128, 128);
ctx.strokeRect(128, 0, 128, 128);
ctx.strokeRect(0, 128, 128, 128);
});
}
function celula(mapa: Mapa, cx: number, cz: number): "#" | "M" | "." {
if (cx < 0 || cz < 0 || cx >= mapa.largura || cz >= mapa.altura) return ".";
const i = cz * mapa.largura + cx;
if (mapa.opaco[i]) return "#";
return mapa.solido[i] ? "M" : ".";
}
/** Parede de dentro com uma só vizinha de parede: é a ponta de um trecho, onde vai um pilar. */
function ehPilar(mapa: Mapa, cx: number, cz: number): boolean {
const borda =
cx === 0 || cz === 0 || cx === mapa.largura - 1 || cz === mapa.altura - 1;
if (borda) return false;
const vizinhas = [
celula(mapa, cx + 1, cz),
celula(mapa, cx - 1, cz),
celula(mapa, cx, cz + 1),
celula(mapa, cx, cz - 1),
].filter((c) => c === "#").length;
return vizinhas === 1;
}
function instancias(
geometria: BoxGeometry,
material: Material,
matrizes: readonly Matrix4[],
): InstancedMesh {
const malha = new InstancedMesh(geometria, material, matrizes.length);
matrizes.forEach((m, i) => {
malha.setMatrixAt(i, m);
});
return malha;
}
export function criarAmbiente(
cena: Scene,
mapa: Mapa,
opcoes: { camera: Camera; movimentoReduzido: boolean },
): Ambiente {
const objetos: Object3D[] = [];
const pos = new Object3D();
const matriz = (x: number, y: number, z: number, giro = 0) => {
pos.position.set(x, y, z);
pos.rotation.set(0, giro, 0);
pos.updateMatrix();
return pos.matrix.clone();
};
cena.background = new Color(NOITE);
// a neblina só engole o fundo: a 12 m ainda se vê a sala e quem vem nela
cena.fog = new Fog(NOITE, 9, 30);
const texturas = [
repetir(texturaDoCarpete(), mapa.largura / 2, mapa.altura / 2),
texturaDaParede(),
repetir(texturaDoForro(), mapa.largura / 1.2, mapa.altura / 1.2),
];
const [carpete, paredeTex, forroTex] = texturas;
const piso = new Mesh(
new PlaneGeometry(mapa.largura, mapa.altura),
new MeshStandardMaterial({ map: carpete, color: "#ffffff", roughness: 1 }),
);
piso.rotation.x = -Math.PI / 2;
piso.position.set(mapa.largura / 2, 0, mapa.altura / 2);
piso.receiveShadow = true;
const forro = new Mesh(
new PlaneGeometry(mapa.largura, mapa.altura),
new MeshStandardMaterial({ map: forroTex, roughness: 1 }),
);
forro.rotation.x = Math.PI / 2;
forro.position.set(mapa.largura / 2, PE_DIREITO, mapa.altura / 2);
const paredes: Matrix4[] = [];
const pilares: Matrix4[] = [];
const mesas: Matrix4[] = [];
const monitores: Matrix4[] = [];
const telas: Matrix4[] = [];
const coresDasTelas: Color[] = [];
const paineis: Matrix4[] = [];
const centrosDosPaineis: [number, number][] = [];
for (let cz = 0; cz < mapa.altura; cz++) {
for (let cx = 0; cx < mapa.largura; cx++) {
const c = celula(mapa, cx, cz);
const x = cx + 0.5;
const z = cz + 0.5;
if (c === "#") {
(ehPilar(mapa, cx, cz) ? pilares : paredes).push(
matriz(x, PE_DIREITO / 2, z),
);
continue;
}
if (cx % ESPACO_DOS_PAINEIS === 2 && cz % ESPACO_DOS_PAINEIS === 2) {
paineis.push(matriz(x, PE_DIREITO - 0.02, z));
centrosDosPaineis.push([x, z]);
}
if (c !== "M") continue;
mesas.push(matriz(x, ALTURA_DA_MESA / 2, z));
// Na fileira de cima do bloco de mesas a tela olha para −z; na de baixo, para +z.
const paraTras = celula(mapa, cx, cz + 1) === "M";
const sentido = paraTras ? -1 : 1;
const mz = z - sentido * 0.25;
const giro = paraTras ? Math.PI : 0;
monitores.push(matriz(x, ALTURA_DA_MESA + 0.25, mz, giro));
telas.push(matriz(x, ALTURA_DA_MESA + 0.26, mz + sentido * 0.03, giro));
coresDasTelas.push((cx * 7 + cz * 3) % 5 === 0 ? LIMA : AZUL_FRIO);
}
}
const paredeMat = new MeshStandardMaterial({
map: paredeTex,
roughness: 0.95,
});
const malhaDasParedes = instancias(
new BoxGeometry(1, PE_DIREITO, 1),
paredeMat,
paredes,
);
const malhaDosPilares = instancias(
new BoxGeometry(1.16, PE_DIREITO, 1.16),
new MeshStandardMaterial({ map: paredeTex, roughness: 0.85 }),
pilares,
);
const malhaDasMesas = instancias(
new BoxGeometry(0.98, ALTURA_DA_MESA, 0.98),
new MeshStandardMaterial({ color: "#3b342c", roughness: 0.7 }),
mesas,
);
const malhaDosMonitores = instancias(
new BoxGeometry(0.56, 0.36, 0.05),
new MeshStandardMaterial({ color: "#111214", roughness: 0.5 }),
monitores,
);
const malhaDasTelas = instancias(
new BoxGeometry(0.5, 0.3, 0.01),
new MeshBasicMaterial({ color: "#ffffff" }),
telas,
);
coresDasTelas.forEach((cor, i) => {
malhaDasTelas.setColorAt(i, cor);
});
for (const malha of [malhaDasParedes, malhaDosPilares, malhaDasMesas]) {
malha.castShadow = true;
malha.receiveShadow = true;
}
// Painéis do teto: a maioria apagada; os mais perto de `PAINEIS_ACESOS` acendem e ganham luz.
const malhaDosPaineis = instancias(
new BoxGeometry(1.2, 0.03, 0.6),
new MeshBasicMaterial({ color: "#ffffff" }),
paineis,
);
centrosDosPaineis.forEach((_, i) => {
malhaDosPaineis.setColorAt(i, PAINEL_APAGADO);
});
const luzesDosPaineis = PAINEIS_ACESOS.map(([fx, fz]) => {
const alvo = [fx * mapa.largura, fz * mapa.altura];
let melhor = 0;
centrosDosPaineis.forEach(([x, z], i) => {
const [mx, mz] = centrosDosPaineis[melhor];
if (
Math.hypot(x - alvo[0], z - alvo[1]) <
Math.hypot(mx - alvo[0], mz - alvo[1])
)
melhor = i;
});
malhaDosPaineis.setColorAt(melhor, PAINEL_ACESO);
const [x, z] = centrosDosPaineis[melhor];
const luz = new PointLight("#cfe0ff", 15, 15, 1.4);
luz.position.set(x, PE_DIREITO - 0.3, z);
return { luz, indice: melhor };
});
// Nascedouros: uma porta corta-fogo na parede do canto, com luminária vermelha em cima. É de lá que os zumbis vêm.
const portas: Matrix4[] = [];
const luminarias: Matrix4[] = [];
const luzesDeEmergencia = mapa.nascedouros.map(([x, z]) => {
const cx = Math.floor(x);
const cz = Math.floor(z);
// a face de parede vizinha e o giro que vira a frente da porta (+z) para dentro da sala
let [px, pz, giro] = [x, cz, 0];
if (celula(mapa, cx, cz + 1) === "#") [px, pz, giro] = [x, cz + 1, Math.PI];
else if (celula(mapa, cx - 1, cz) === "#")
[px, pz, giro] = [cx, z, Math.PI / 2];
else if (celula(mapa, cx + 1, cz) === "#")
[px, pz, giro] = [cx + 1, z, -Math.PI / 2];
const base = matriz(px, 0, pz, giro);
for (const [l, a, pr, ox, oy, oz] of PECAS_DA_PORTA)
portas.push(
base
.clone()
.multiply(new Matrix4().makeScale(l, a, pr).setPosition(ox, oy, oz)),
);
luminarias.push(
base
.clone()
.multiply(
new Matrix4().makeScale(0.56, 0.12, 0.1).setPosition(0, 2.3, 0.05),
),
);
const luz = new PointLight("#ff2020", 12, 10, 1.3);
luz.position.set(px + Math.sin(giro) * 0.6, 2.2, pz + Math.cos(giro) * 0.6);
return luz;
});
const malhaDasPortas = instancias(
new BoxGeometry(1, 1, 1),
new MeshStandardMaterial({ color: "#ffffff", roughness: 0.6 }),
portas,
);
portas.forEach((_, i) => {
malhaDasPortas.setColorAt(i, CORES_DA_PORTA[i % PECAS_DA_PORTA.length]);
});
const malhaDaEmergencia = instancias(
new BoxGeometry(1, 1, 1),
new MeshBasicMaterial({ color: "#ff1a1a" }),
luminarias,
);
// Luz de fundo fria, do forro para o chão: dá silhueta e forma à sala fora da lanterna, sem tirar a noite.
const ambiente = new HemisphereLight("#7d90c4", "#2a2622", 1.8);
// Lanterna presa à câmera, à esquerda da arma e abaixo dos olhos: a única luz com sombra. Daqui a arma fica na borda
// do facho e pega luz de lado, sem estourar de branco.
const lanterna = new SpotLight("#fff2dc", 40, 24, 0.5, 0.45, 1.5);
lanterna.position.set(-0.06, -0.14, 0.02);
lanterna.target.position.set(0, 0, -6);
lanterna.castShadow = true;
lanterna.shadow.mapSize.set(1024, 1024);
lanterna.shadow.bias = -0.0005;
lanterna.shadow.normalBias = 0.02;
lanterna.shadow.camera.near = 0.3;
opcoes.camera.add(lanterna, lanterna.target);
objetos.push(
piso,
forro,
malhaDasParedes,
malhaDosPilares,
malhaDasMesas,
malhaDosMonitores,
malhaDasTelas,
malhaDosPaineis,
malhaDasPortas,
malhaDaEmergencia,
ambiente,
...luzesDosPaineis.map((l) => l.luz),
...luzesDeEmergencia,
);
cena.add(...objetos);
const piscante = luzesDosPaineis[luzesDosPaineis.length - 1];
const intensidade = piscante.luz.intensity;
let aceso = true;
return {
definirSombras(ligadas) {
lanterna.castShadow = ligadas;
},
atualizar(segundos) {
if (opcoes.movimentoReduzido) return;
// apagões curtos e irregulares, como reator de lâmpada velha
const agora =
Math.sin(segundos * 23) * Math.sin(segundos * 7.3) +
Math.sin(segundos * 1.7) >
-0.6;
if (agora === aceso) return;
aceso = agora;
piscante.luz.intensity = aceso ? intensidade : 0;
malhaDosPaineis.setColorAt(
piscante.indice,
aceso ? PAINEL_ACESO : PAINEL_APAGADO,
);
if (malhaDosPaineis.instanceColor)
malhaDosPaineis.instanceColor.needsUpdate = true;
},
descartar() {
lanterna.shadow.dispose();
lanterna.removeFromParent();
lanterna.target.removeFromParent();
cena.remove(...objetos);
for (const objeto of objetos) {
if (!(objeto instanceof Mesh)) continue;
objeto.geometry.dispose();
(objeto.material as Material).dispose();
if (objeto instanceof InstancedMesh) objeto.dispose();
}
for (const textura of texturas) textura.dispose();
cena.fog = null;
cena.background = null;
},
};
}