/** * 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 ``. */ 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 ``; 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; }, }; }