Files
portifolio-evandrodevbr/app/jogo/ambiente.ts
T
evandro 67b32fcab3
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s
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.
2026-11-10 19:37:10 -03:00

481 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
},
};
}