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.
481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
/**
|
||
* 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;
|
||
},
|
||
};
|
||
}
|