feat: add cs-dust2 React case with two model takes + react kind support

New case cs-dust2 (kind:react): a from-scratch React + three.js 5v5 Dust2 FPS.
Two takes registered: Claude Fable 5 / Claude Code / High (with a hand fix for a
64Hz fixed-step firing bug on high-refresh displays) and Claude Opus 4.8 /
Claude Code / Max (dust2-arena, one-shot). React cases commit source only; the
single file is built from source at deploy time. validate-data.ts now accepts a
React project directory as the declared artifact. AGENTS.md rewritten
English-only with React-case, bare-harness (no skills/plugins) and
artifact-anonymity rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jesse ZhangandClaude Opus 4.8 committed 2026-07-14 00:08:28 +01:00
1 parent c4cc293748
commit cbfce3b7cc
62 files changed
+13583 -43

No files matched your search

@@ -0,0 +1,10 @@
import { useState } from 'react';
import { Team } from './engine/types';
import { Menu } from './components/Menu';
import { GameCanvas } from './components/GameCanvas';
export function App() {
const [team, setTeam] = useState<Team | null>(null);
if (team === null) return <Menu onStart={setTeam} />;
return <GameCanvas team={team} />;
}
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react';
import { GameWorld } from '../engine/world';
import { Team } from '../engine/types';
import { Hud } from './Hud';
import { Minimap } from './Minimap';
export function GameCanvas({ team }: { team: Team }) {
const mountRef = useRef<HTMLDivElement>(null);
const miniRef = useRef<HTMLCanvasElement>(null);
const worldRef = useRef<GameWorld | null>(null);
const [locked, setLocked] = useState(false);
const [started, setStarted] = useState(false);
useEffect(() => {
const container = mountRef.current!;
const world = new GameWorld(container, team);
worldRef.current = world;
if (miniRef.current) world.setMinimapCanvas(miniRef.current);
let raf = 0;
let last = performance.now();
const loop = (t: number) => {
const dt = (t - last) / 1000;
last = t;
world.update(dt);
world.input.endFrame();
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
const onResize = () => world.resize();
window.addEventListener('resize', onResize);
const onLockChange = () => setLocked(document.pointerLockElement === world.renderer.domElement);
document.addEventListener('pointerlockchange', onLockChange);
const onClick = () => {
world.start();
setStarted(true);
world.input.requestLock();
};
container.addEventListener('click', onClick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
document.removeEventListener('pointerlockchange', onLockChange);
container.removeEventListener('click', onClick);
world.dispose();
worldRef.current = null;
};
}, [team]);
return (
<div className="game-root" ref={mountRef}>
<Hud />
<Minimap canvasRef={miniRef} />
{!locked && (
<div className="click-overlay">
<div className="click-inner">
<div className="click-title">{started ? 'Paused' : 'Ready'}</div>
<div className="click-hint">Click to {started ? 'resume' : 'lock mouse and start'}</div>
<div className="click-sub">Esc releases the mouse</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,166 @@
import { useSyncExternalStore } from 'react';
import { hud } from '../engine/store';
import { Team } from '../engine/types';
import { WEAPONS } from '../engine/weapons';
function useHud() {
return useSyncExternalStore(hud.subscribe, hud.getSnapshot, hud.getSnapshot);
}
const T_COLOR = '#e0a24a';
const CT_COLOR = '#5aa9e6';
const teamColor = (t: Team) => (t === Team.T ? T_COLOR : CT_COLOR);
function fmt(t: number): string {
const s = Math.max(0, Math.ceil(t));
const m = Math.floor(s / 60);
const ss = s % 60;
return `${m}:${ss.toString().padStart(2, '0')}`;
}
function Crosshair({ spread, hit }: { spread: number; hit: boolean }) {
const gap = Math.max(3, Math.min(90, spread * 600));
const len = 7;
const thick = 2;
const col = hit ? '#ff5b5b' : '#e7fff0';
const line = (style: React.CSSProperties) => (
<span style={{ position: 'absolute', background: col, ...style }} />
);
return (
<div className="crosshair">
{line({ width: thick, height: len, left: -thick / 2, top: -gap - len })}
{line({ width: thick, height: len, left: -thick / 2, top: gap })}
{line({ width: len, height: thick, top: -thick / 2, left: -gap - len })}
{line({ width: len, height: thick, top: -thick / 2, left: gap })}
<span style={{ position: 'absolute', width: 2, height: 2, left: -1, top: -1, background: col }} />
</div>
);
}
export function Hud() {
const s = useHud();
return (
<div className="hud">
{/* top score bar */}
<div className="topbar">
<div className="team-block" style={{ color: T_COLOR }}>
<span className="team-tag">T</span>
<span className="team-alive">{'●'.repeat(s.aliveT)}<span className="dead-dots">{'○'.repeat(Math.max(0, 5 - s.aliveT))}</span></span>
</div>
<div className="score-center">
<span className="score" style={{ color: T_COLOR }}>{s.scoreT}</span>
<span className="score-time">{s.bombPlanted ? <b className="bomb-time">{fmt(s.bombTimer)}</b> : fmt(s.roundTime)}</span>
<span className="score" style={{ color: CT_COLOR }}>{s.scoreCT}</span>
</div>
<div className="team-block team-right" style={{ color: CT_COLOR }}>
<span className="team-alive"><span className="dead-dots">{'○'.repeat(Math.max(0, 5 - s.aliveCT))}</span>{'●'.repeat(s.aliveCT)}</span>
<span className="team-tag">CT</span>
</div>
</div>
{/* killfeed */}
<div className="killfeed">
{s.killfeed.map((k) => (
<div className="kill-row" key={k.id}>
<span style={{ color: teamColor(k.attackerTeam) }}>{k.attacker}</span>
<span className="kill-weapon">{WEAPONS[k.weapon]?.name ?? k.weapon}{k.headshot ? ' ×' : ''}</span>
<span style={{ color: teamColor(k.victimTeam) }}>{k.victim}</span>
</div>
))}
</div>
{/* center banner */}
{s.banner && <div className="banner">{s.banner}</div>}
{/* plant / defuse progress */}
{(s.planting || s.defusing) && (
<div className="progress-wrap">
<div className="progress-label">{s.planting ? 'PLANTING' : 'DEFUSING'}</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${Math.min(100, (s.planting ? s.plantProgress : s.defuseProgress) * 100)}%`,
background: s.planting ? T_COLOR : CT_COLOR,
}}
/>
</div>
</div>
)}
{/* scope overlay */}
{s.scoped && (
<div className="scope">
<div className="scope-circle" />
<div className="scope-line scope-h" />
<div className="scope-line scope-v" />
</div>
)}
{/* crosshair */}
{!s.scoped && (s.alive || s.spectating) && <Crosshair spread={s.spread} hit={false} />}
{/* dead: spectate bar, or eliminated when no teammate left */}
{!s.alive && s.spectating && (
<div className="spectate">
<div className="spectate-title">
SPECTATING <span className="spectate-name">{s.spectateName}</span>
</div>
<div className="spectate-keys">
<b>[F]</b> / <b>Space</b> take control&nbsp;&nbsp;·&nbsp;&nbsp;<b>Mouse1</b> / <b>A</b> / <b>D</b> switch player
</div>
</div>
)}
{!s.alive && !s.spectating && (
<div className="dead">
<div className="dead-title">ELIMINATED</div>
<div className="dead-sub">No teammates left — round ending</div>
</div>
)}
{/* hint */}
{s.hint && s.alive && <div className="hint">{s.hint}</div>}
{/* bomb carrier tag */}
{s.hasBomb && s.alive && <div className="bomb-tag">C4 — bring it to A or B</div>}
{/* bottom-left: health / armor */}
<div className="vitals">
<div className="vital">
<span className="vital-icon hp">+</span>
<span className="vital-num" style={{ color: s.health < 30 ? '#ff5b5b' : '#fff' }}>{s.health}</span>
</div>
<div className="vital">
<span className="vital-icon ar">{s.helmet ? 'A*' : 'A'}</span>
<span className="vital-num">{s.armor}</span>
</div>
</div>
{/* bottom-right: ammo + weapons */}
<div className="ammo-block">
<div className="ammo-weapon">{s.weaponName}</div>
<div className="ammo-nums">
{s.infiniteAmmo ? (
<span className="ammo-mag">∞</span>
) : (
<>
<span className="ammo-mag" style={{ color: s.reloading ? '#ffcf5b' : '#fff' }}>
{s.reloading ? 'R' : s.mag}
</span>
<span className="ammo-res">/ {s.reserve}</span>
</>
)}
</div>
<div className="weapon-slots">
{s.loadout.map((w) => (
<div key={w.id} className={`slot ${w.current ? 'slot-cur' : ''}`}>
<span className="slot-key">{w.slot === 'primary' ? '1' : w.slot === 'secondary' ? '2' : '3'}</span>
<span className="slot-name">{w.name}</span>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { useState } from 'react';
import { Team } from '../engine/types';
export function Menu({ onStart }: { onStart: (t: Team) => void }) {
const [team, setTeam] = useState<Team>(Team.T);
return (
<div className="menu">
<div className="menu-card">
<h1 className="menu-title">DUST II ARENA</h1>
<p className="menu-sub">5v5 procedural first-person prototype — pick a side</p>
<div className="side-select">
<button
className={`side side-t ${team === Team.T ? 'active' : ''}`}
onClick={() => setTeam(Team.T)}
>
<span className="side-name">Terrorists</span>
<span className="side-desc">Carry &amp; plant the C4 · attack A or B</span>
</button>
<button
className={`side side-ct ${team === Team.CT ? 'active' : ''}`}
onClick={() => setTeam(Team.CT)}
>
<span className="side-name">Counter-Terrorists</span>
<span className="side-desc">Hold the sites · defuse the C4</span>
</button>
</div>
<div className="controls">
<div className="ctrl-col">
<b>Move</b> WASD · <b>Jump</b> Space · <b>Walk</b> Shift
</div>
<div className="ctrl-col">
<b>Fire</b> Mouse1 · <b>Scope</b> Mouse2 (AWP) · <b>Reload</b> R
</div>
<div className="ctrl-col">
<b>Weapons</b> 1 / 2 / 3 · wheel · <b>Swap AWP</b> B
</div>
<div className="ctrl-col">
<b>Plant / Defuse / Pickup</b> hold E
</div>
<div className="ctrl-col">
<b>When dead</b> Mouse1 / A / D switch view · F take control
</div>
</div>
<button className="start-btn" onClick={() => onStart(team)}>
Enter Match
</button>
<p className="menu-foot">Round 1 is a pistol round. Click the arena to lock the mouse.</p>
</div>
</div>
);
}
@@ -0,0 +1,10 @@
import { RefObject } from 'react';
export function Minimap({ canvasRef }: { canvasRef: RefObject<HTMLCanvasElement | null> }) {
return (
<div className="minimap">
<canvas ref={canvasRef as RefObject<HTMLCanvasElement>} width={200} height={200} />
<div className="minimap-label">DUST II</div>
</div>
);
}
@@ -0,0 +1,330 @@
// Bot brain: a compact state machine driving perception, navigation (grid A*),
// combat (face + strafe + burst fire with reaction delay + aim error) and the
// C4 objective (T push/plant, CT hold/defuse). It only sets an actor's intents
// and calls world combat helpers; physics + firing live in GameWorld.
import { AI } from './constants';
import { findPath } from './navmesh';
import { angleDelta, moveAngleToward, rand, yawFromDir } from './mathUtils';
import { BombState, Team } from './types';
import { WEAPONS } from './weapons';
import type { Actor, GameWorld } from './world';
export type BotMode = 'advance' | 'engage' | 'search' | 'hold' | 'plant' | 'defuse' | 'pickup';
export interface BotState {
mode: BotMode;
path: { x: number; z: number }[];
pathIdx: number;
repath: number;
goalX: number;
goalZ: number;
hasGoal: boolean;
target: number;
lastSeen: number;
lastSeenX: number;
lastSeenZ: number;
reactAt: number;
nextBurst: number;
burstUntil: number;
strafeDir: number;
strafeUntil: number;
wanderYaw: number;
targetSite?: 'A' | 'B';
defendSite?: 'A' | 'B';
}
export function initBotState(s?: BotState): BotState {
const base: BotState = {
mode: 'advance',
path: [],
pathIdx: 0,
repath: 0,
goalX: 0,
goalZ: 0,
hasGoal: false,
target: -1,
lastSeen: -99,
lastSeenX: 0,
lastSeenZ: 0,
reactAt: 0,
nextBurst: 0,
burstUntil: 0,
strafeDir: 1,
strafeUntil: 0,
wanderYaw: 0,
};
if (s) {
const keepT = s.targetSite;
const keepD = s.defendSite;
Object.assign(s, base);
s.targetSite = keepT;
s.defendSite = keepD;
return s;
}
return base;
}
function setGoal(world: GameWorld, a: Actor, gx: number, gz: number): void {
const bs = a.bs;
const moved = (gx - bs.goalX) ** 2 + (gz - bs.goalZ) ** 2 > 9;
bs.goalX = gx;
bs.goalZ = gz;
bs.hasGoal = true;
if (moved || !bs.path.length || bs.repath <= 0) {
const p = findPath(world.grid, a.x, a.z, gx, gz);
bs.path = p ?? [];
bs.pathIdx = 0;
bs.repath = AI.repathInterval;
}
}
// steer along the current path; returns false when the path is exhausted
function followPath(a: Actor): boolean {
const bs = a.bs;
if (bs.pathIdx >= bs.path.length) return false;
let wp = bs.path[bs.pathIdx];
let dx = wp.x - a.x;
let dz = wp.z - a.z;
let d = Math.hypot(dx, dz);
while (d < 1.1 && bs.pathIdx < bs.path.length - 1) {
bs.pathIdx++;
wp = bs.path[bs.pathIdx];
dx = wp.x - a.x;
dz = wp.z - a.z;
d = Math.hypot(dx, dz);
}
if (bs.pathIdx >= bs.path.length - 1 && d < 1.1) {
bs.pathIdx = bs.path.length;
return false;
}
if (d > 1e-3) {
a.moveX = dx / d;
a.moveZ = dz / d;
}
return true;
}
export function updateBot(world: GameWorld, a: Actor, dt: number, canMove: boolean): void {
const bs = a.bs;
bs.repath -= dt;
a.moveX = 0;
a.moveZ = 0;
a.wantRun = true;
a.action = 'none';
// --- perception ---
const seen = world.nearestVisibleEnemy(a, AI.fovDeg);
if (seen) {
if (bs.target !== seen.id) {
bs.target = seen.id;
bs.reactAt = world.time + AI.reactionTime + rand(0, 0.12);
}
bs.lastSeen = world.time;
bs.lastSeenX = seen.x;
bs.lastSeenZ = seen.z;
bs.mode = 'engage';
} else if (bs.mode === 'engage') {
bs.mode = world.time - bs.lastSeen < 3 ? 'search' : 'advance';
bs.target = -1;
}
if (!canMove) {
// during freeze, just face forward
return;
}
const bomb = world.bombInfo();
if (bs.mode === 'engage' && seen) {
combat(world, a, seen, dt);
return;
}
// --- objective / navigation ---
if (bs.mode === 'search') {
setGoal(world, a, bs.lastSeenX, bs.lastSeenZ);
if ((bs.lastSeenX - a.x) ** 2 + (bs.lastSeenZ - a.z) ** 2 < 4 || !followPathOrArrive(world, a)) {
bs.mode = 'advance';
}
faceMovement(a, dt);
return;
}
if (a.team === Team.T) {
objectiveT(world, a, bomb.state, bomb.x, bomb.z);
} else {
objectiveCT(world, a, bomb.state, bomb.x, bomb.z);
}
faceMovement(a, dt);
}
function followPathOrArrive(world: GameWorld, a: Actor): boolean {
const moving = followPath(a);
if (!moving) {
// recompute once toward goal in case we stalled
if (a.bs.hasGoal) {
const d2 = (a.bs.goalX - a.x) ** 2 + (a.bs.goalZ - a.z) ** 2;
if (d2 > 4 && a.bs.repath <= 0) setGoal(world, a, a.bs.goalX, a.bs.goalZ);
}
return false;
}
return true;
}
function objectiveT(world: GameWorld, a: Actor, bombState: BombState, bx: number, bz: number): void {
const bs = a.bs;
const site = world.dust2.sites[(bs.targetSite ?? 'A') as 'A' | 'B'];
if (bombState === BombState.Dropped) {
// nearest T without bomb goes for the pickup
if (isNearestTeammateTo(world, a, bx, bz)) {
setGoal(world, a, bx, bz);
followPathOrArrive(world, a);
world.pickupBomb(a);
return;
}
}
if (a.hasBomb && bombState === BombState.Carried) {
const inSite = world.isInSite(a) === bs.targetSite || world.isInSite(a) !== null;
if (inSite && a.grounded) {
// plant if no enemy currently threatening
a.action = 'plant';
a.moveX = a.moveZ = 0;
return;
}
setGoal(world, a, site.center.x, site.center.z);
followPathOrArrive(world, a);
return;
}
if (bombState === BombState.Planted) {
// defend near the bomb
const spot = spreadAround(a, bx, bz, 8);
setGoal(world, a, spot.x, spot.z);
followPathOrArrive(world, a);
return;
}
// support push toward the target site
const spot = spreadAround(a, site.center.x, site.center.z, 7);
setGoal(world, a, spot.x, spot.z);
followPathOrArrive(world, a);
}
function objectiveCT(world: GameWorld, a: Actor, bombState: BombState, bx: number, bz: number): void {
const bs = a.bs;
if (bombState === BombState.Planted) {
// rotate to defuse
const dist2 = (bx - a.x) ** 2 + (bz - a.z) ** 2;
if (dist2 < 2.3 * 2.3 && a.grounded) {
a.action = 'defuse';
a.moveX = a.moveZ = 0;
return;
}
setGoal(world, a, bx, bz);
followPathOrArrive(world, a);
return;
}
// hold the assigned site
const site = world.dust2.sites[(bs.defendSite ?? 'A') as 'A' | 'B'];
const d2 = (site.center.x - a.x) ** 2 + (site.center.z - a.z) ** 2;
if (d2 > 10 * 10) {
setGoal(world, a, site.center.x, site.center.z);
followPathOrArrive(world, a);
} else {
// patrol slowly around the site
if (world.time > a.bs.strafeUntil) {
a.bs.strafeUntil = world.time + rand(2, 4);
const ang = rand(0, Math.PI * 2);
setGoal(world, a, site.center.x + Math.cos(ang) * 6, site.center.z + Math.sin(ang) * 6);
}
a.wantRun = false;
followPathOrArrive(world, a);
}
}
function isNearestTeammateTo(world: GameWorld, a: Actor, x: number, z: number): boolean {
const myD = (a.x - x) ** 2 + (a.z - z) ** 2;
for (const t of world.actors) {
if (!t.alive || t.team !== a.team || t.id === a.id || t.isPlayer) continue;
const d = (t.x - x) ** 2 + (t.z - z) ** 2;
if (d < myD) return false;
}
return true;
}
function spreadAround(a: Actor, x: number, z: number, spread: number): { x: number; z: number } {
// deterministic per-bot offset so bots don't stack on one point
const ang = (a.id * 1.3) % (Math.PI * 2);
return { x: x + Math.cos(ang) * spread * 0.7, z: z + Math.sin(ang) * spread * 0.7 };
}
function faceMovement(a: Actor, dt: number): void {
if (Math.abs(a.moveX) + Math.abs(a.moveZ) > 1e-3) {
const desired = yawFromDir(a.moveX, a.moveZ);
a.yaw = moveAngleToward(a.yaw, desired, AI.aimSpeed * dt);
}
a.pitch += (0 - a.pitch) * Math.min(1, dt * 6);
}
function combat(world: GameWorld, a: Actor, target: Actor, dt: number): void {
const bs = a.bs;
const dx = target.x - a.x;
const dz = target.z - a.z;
const dist = Math.hypot(dx, dz);
// aim (with error)
const err = AI.aimError;
const desiredYaw = yawFromDir(dx, dz) + rand(-err, err);
const eyeY = a.y + a.eyeH;
const targetY = target.y + 1.15;
const desiredPitch = Math.atan2(targetY - eyeY, Math.max(0.5, dist)) + rand(-err, err);
a.yaw = moveAngleToward(a.yaw, desiredYaw, AI.aimSpeed * dt);
a.pitch += (desiredPitch - a.pitch) * Math.min(1, dt * 10);
// movement: strafe + keep an ideal range
if (world.time > bs.strafeUntil) {
bs.strafeUntil = world.time + rand(0.5, 1.3);
bs.strafeDir = Math.random() < 0.5 ? 1 : -1;
}
const perpX = -dz / (dist || 1);
const perpZ = dx / (dist || 1);
let mx = perpX * bs.strafeDir;
let mz = perpZ * bs.strafeDir;
const ideal = a.current === 'awp' ? 24 : 14;
if (dist > ideal + 6) {
mx += dx / dist;
mz += dz / dist;
} else if (dist < ideal - 6) {
mx -= dx / dist;
mz -= dz / dist;
}
const ml = Math.hypot(mx, mz) || 1;
a.moveX = mx / ml;
a.moveZ = mz / ml;
a.wantRun = false; // walk while fighting = more accurate
// fire control
const w = WEAPONS[a.current];
const ammo = a.ammo[a.current];
if (ammo && ammo.mag <= 0) {
world.startReload(a);
return;
}
const aligned = Math.abs(angleDelta(a.yaw, desiredYaw)) < 0.13;
const reacted = world.time >= bs.reactAt;
if (!reacted || !aligned) return;
if (!world.canSee(a, target, 200)) return;
if (w.automatic) {
if (world.time > bs.nextBurst) {
bs.burstUntil = world.time + rand(AI.burstMin, AI.burstMax);
bs.nextBurst = bs.burstUntil + rand(0.18, 0.45);
}
if (world.time <= bs.burstUntil) world.fireWeapon(a);
} else {
world.fireWeapon(a);
}
}
@@ -0,0 +1,233 @@
// Procedural sound via the Web Audio API (no audio files). World sounds are
// attenuated + panned relative to the player listener; the player's own weapon
// plays centered. Everything is short transients synthesized from noise + tones.
import { WeaponId } from './types';
export class AudioEngine {
private ctx: AudioContext | null = null;
private master: GainNode | null = null;
private noise: AudioBuffer | null = null;
private lx = 0;
private lz = 0;
private lyaw = 0;
muted = false;
init(): void {
if (this.ctx) {
if (this.ctx.state === 'suspended') void this.ctx.resume();
return;
}
const Ctor: typeof AudioContext =
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext ??
window.AudioContext;
this.ctx = new Ctor();
this.master = this.ctx.createGain();
this.master.gain.value = 0.5;
this.master.connect(this.ctx.destination);
// one second of white noise, reused everywhere
const len = this.ctx.sampleRate;
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
this.noise = buf;
}
setListener(x: number, z: number, yaw: number): void {
this.lx = x;
this.lz = z;
this.lyaw = yaw;
}
private now(): number {
return this.ctx ? this.ctx.currentTime : 0;
}
// Spatial gain + stereo pan for a world position, or null if inaudible.
private spatial(x: number, z: number, maxDist: number): { gain: number; pan: number } | null {
const dx = x - this.lx;
const dz = z - this.lz;
const dist = Math.hypot(dx, dz);
if (dist > maxDist) return null;
const gain = Math.pow(1 - dist / maxDist, 1.6);
// rotate offset into listener frame; pan by the right-vector component
const c = Math.cos(this.lyaw);
const s = Math.sin(this.lyaw);
// right vector (matches -Z forward, YXZ): (cos, -sin)
const right = (dx * c - dz * -s) / (dist + 1e-3);
const pan = Math.max(-1, Math.min(1, right));
return { gain, pan };
}
private out(pan: number): AudioNode {
const ctx = this.ctx!;
if (typeof ctx.createStereoPanner === 'function') {
const p = ctx.createStereoPanner();
p.pan.value = pan;
p.connect(this.master!);
return p;
}
return this.master!;
}
private burst(
dur: number, type: BiquadFilterType, freq: number, q: number,
gain: number, pan: number, when = 0,
): void {
if (!this.ctx || !this.noise) return;
const ctx = this.ctx;
const t = this.now() + when;
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.loop = true;
const filt = ctx.createBiquadFilter();
filt.type = type;
filt.frequency.value = freq;
filt.Q.value = q;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.002);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(filt);
filt.connect(g);
g.connect(this.out(pan));
src.start(t);
src.stop(t + dur + 0.02);
}
private tone(
freq: number, dur: number, type: OscillatorType, gain: number, pan: number,
when = 0, glideTo?: number,
): void {
if (!this.ctx) return;
const ctx = this.ctx;
const t = this.now() + when;
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
if (glideTo !== undefined) osc.frequency.exponentialRampToValueAtTime(glideTo, t + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.004);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(g);
g.connect(this.out(pan));
osc.start(t);
osc.stop(t + dur + 0.02);
}
// --- public sound events ---
gunshot(weapon: WeaponId, x?: number, z?: number): void {
if (this.muted || !this.ctx) return;
let sp = { gain: 1, pan: 0 };
if (x !== undefined && z !== undefined) {
const s = this.spatial(x, z, 95);
if (!s) return;
sp = s;
}
const v = sp.gain;
switch (weapon) {
case 'ak47':
this.burst(0.16, 'bandpass', 1400, 0.7, 0.55 * v, sp.pan);
this.burst(0.09, 'lowpass', 500, 1, 0.5 * v, sp.pan);
this.tone(120, 0.1, 'sawtooth', 0.25 * v, sp.pan, 0, 60);
break;
case 'm4a4':
this.burst(0.11, 'bandpass', 2100, 0.9, 0.42 * v, sp.pan);
this.burst(0.06, 'lowpass', 700, 1, 0.35 * v, sp.pan);
this.tone(160, 0.07, 'square', 0.16 * v, sp.pan, 0, 90);
break;
case 'awp':
this.burst(0.35, 'lowpass', 900, 0.8, 0.7 * v, sp.pan);
this.burst(0.2, 'bandpass', 1600, 0.6, 0.5 * v, sp.pan);
this.tone(80, 0.3, 'sawtooth', 0.4 * v, sp.pan, 0, 40);
break;
case 'deagle':
this.burst(0.18, 'bandpass', 1100, 0.7, 0.55 * v, sp.pan);
this.tone(110, 0.12, 'square', 0.28 * v, sp.pan, 0, 55);
break;
case 'glock':
this.burst(0.08, 'bandpass', 1800, 1, 0.32 * v, sp.pan);
this.tone(220, 0.05, 'square', 0.12 * v, sp.pan, 0, 130);
break;
case 'usp':
this.burst(0.07, 'bandpass', 1500, 1.2, 0.26 * v, sp.pan);
this.tone(260, 0.04, 'sine', 0.1 * v, sp.pan, 0, 150);
break;
case 'knife':
this.burst(0.05, 'highpass', 3000, 0.6, 0.25 * v, sp.pan);
break;
}
}
reload(): void {
if (this.muted) return;
this.burst(0.05, 'bandpass', 900, 2, 0.25, 0, 0.0);
this.burst(0.05, 'bandpass', 1400, 2, 0.22, 0, 0.35);
this.tone(500, 0.03, 'square', 0.12, 0, 0.7);
}
footstep(x?: number, z?: number): void {
if (this.muted || !this.ctx) return;
let sp = { gain: 0.5, pan: 0 };
if (x !== undefined && z !== undefined) {
const s = this.spatial(x, z, 20);
if (!s) return;
sp = s;
}
this.burst(0.07, 'lowpass', 380, 1.2, 0.18 * sp.gain, sp.pan);
}
scope(on: boolean): void {
if (this.muted) return;
this.tone(on ? 700 : 500, 0.05, 'sine', 0.14, 0);
this.tone(on ? 900 : 400, 0.05, 'sine', 0.1, 0, 0.04);
}
hitmarker(): void {
if (this.muted) return;
this.tone(1400, 0.05, 'square', 0.16, 0);
}
kill(): void {
if (this.muted) return;
this.tone(880, 0.08, 'square', 0.2, 0);
this.tone(1320, 0.1, 'square', 0.2, 0, 0.08);
}
bombBeep(x?: number, z?: number): void {
if (this.muted) return;
let sp = { gain: 0.6, pan: 0 };
if (x !== undefined && z !== undefined) {
const s = this.spatial(x, z, 45);
if (!s) return;
sp = s;
}
this.tone(2000, 0.06, 'sine', 0.18 * sp.gain, sp.pan);
}
plantTick(): void {
if (this.muted) return;
this.tone(1200, 0.03, 'square', 0.12, 0);
}
defuseTick(): void {
if (this.muted) return;
this.burst(0.04, 'bandpass', 700, 3, 0.14, 0);
}
explode(x?: number, z?: number): void {
if (this.muted || !this.ctx) return;
let sp = { gain: 1, pan: 0 };
if (x !== undefined && z !== undefined) {
const s = this.spatial(x, z, 140);
if (!s) return;
sp = s;
}
const v = sp.gain;
this.burst(0.9, 'lowpass', 400, 0.7, 0.9 * v, sp.pan);
this.burst(0.5, 'bandpass', 900, 0.5, 0.6 * v, sp.pan);
this.tone(60, 0.8, 'sawtooth', 0.6 * v, sp.pan, 0, 30);
}
}
@@ -0,0 +1,153 @@
// Horizontal collision for a vertical-cylinder actor (radius r) against the wall
// grid and crate boxes, plus ground-height sampling for standing on crates.
import { Crate } from './dust2';
import { MapGrid } from './grid';
// Push a circle (cx,cz,r) out of an AABB if overlapping. Returns corrected center.
function pushOutAABB(
cx: number, cz: number, r: number,
minX: number, minZ: number, maxX: number, maxZ: number,
): { x: number; z: number } | null {
const closestX = cx < minX ? minX : cx > maxX ? maxX : cx;
const closestZ = cz < minZ ? minZ : cz > maxZ ? maxZ : cz;
const dx = cx - closestX;
const dz = cz - closestZ;
const d2 = dx * dx + dz * dz;
if (d2 > r * r) return null; // no overlap
if (d2 > 1e-8) {
const d = Math.sqrt(d2);
const push = r - d;
return { x: cx + (dx / d) * push, z: cz + (dz / d) * push };
}
// Center inside the box: push along the least-penetration axis.
const penL = cx - minX;
const penR = maxX - cx;
const penT = cz - minZ;
const penB = maxZ - cz;
const minPen = Math.min(penL, penR, penT, penB);
if (minPen === penL) return { x: minX - r, z: cz };
if (minPen === penR) return { x: maxX + r, z: cz };
if (minPen === penT) return { x: cx, z: minZ - r };
return { x: cx, z: maxZ + r };
}
// Resolve against wall cells overlapping the circle's neighborhood.
export function collideWalls(grid: MapGrid, x: number, z: number, r: number): { x: number; z: number } {
const cs = grid.cell;
let px = x;
let pz = z;
for (let iter = 0; iter < 3; iter++) {
const c0 = grid.cellOf(px - r, pz - r);
const c1 = grid.cellOf(px + r, pz + r);
let moved = false;
for (let cz = c0.cz; cz <= c1.cz; cz++) {
for (let cx = c0.cx; cx <= c1.cx; cx++) {
if (!grid.isWall(cx, cz)) continue;
const minX = grid.originX + cx * cs;
const minZ = grid.originZ + cz * cs;
const res = pushOutAABB(px, pz, r, minX, minZ, minX + cs, minZ + cs);
if (res) {
px = res.x;
pz = res.z;
moved = true;
}
}
}
if (!moved) break;
}
return { x: px, z: pz };
}
// Resolve against crates that the actor cannot stand on at its current feet Y.
export function collideCrates(
crates: Crate[], x: number, z: number, r: number, feetY: number,
): { x: number; z: number } {
let px = x;
let pz = z;
for (let iter = 0; iter < 2; iter++) {
let moved = false;
for (const cr of crates) {
if (cr.height <= feetY + 0.15) continue; // standing on / above it
const res = pushOutAABB(px, pz, r, cr.x - cr.hx, cr.z - cr.hz, cr.x + cr.hx, cr.z + cr.hz);
if (res) {
px = res.x;
pz = res.z;
moved = true;
}
}
if (!moved) break;
}
return { x: px, z: pz };
}
// Highest crate top under the actor's footprint, else 0 (floor).
export function groundHeightAt(crates: Crate[], x: number, z: number, r: number): number {
let h = 0;
for (const cr of crates) {
// Standing counts if the actor center is over the (slightly shrunk) top face.
const mx = cr.hx - r * 0.5;
const mz = cr.hz - r * 0.5;
if (
x >= cr.x - mx && x <= cr.x + mx &&
z >= cr.z - mz && z <= cr.z + mz
) {
if (cr.height > h) h = cr.height;
}
}
return h;
}
// 3D ray vs crate boxes: nearest hit distance within maxT, or maxT if none.
// Used for bullet clipping and line-of-sight through the (partial-height) crates.
export function rayCrateDistance(
crates: Crate[],
ox: number, oy: number, oz: number,
dx: number, dy: number, dz: number,
maxT: number,
): number {
let best = maxT;
for (const cr of crates) {
const t = rayBox3(
ox, oy, oz, dx, dy, dz,
cr.x - cr.hx, 0, cr.z - cr.hz,
cr.x + cr.hx, cr.height, cr.z + cr.hz,
);
if (t >= 0 && t < best) best = t;
}
return best;
}
function rayBox3(
ox: number, oy: number, oz: number,
dx: number, dy: number, dz: number,
minX: number, minY: number, minZ: number,
maxX: number, maxY: number, maxZ: number,
): number {
const o = [ox, oy, oz];
const d = [dx, dy, dz];
const mn = [minX, minY, minZ];
const mx = [maxX, maxY, maxZ];
let tmin = -Infinity;
let tmax = Infinity;
for (let a = 0; a < 3; a++) {
if (Math.abs(d[a]) < 1e-8) {
if (o[a] < mn[a] || o[a] > mx[a]) return -1;
} else {
const inv = 1 / d[a];
let t1 = (mn[a] - o[a]) * inv;
let t2 = (mx[a] - o[a]) * inv;
if (t1 > t2) {
const tmp = t1;
t1 = t2;
t2 = tmp;
}
if (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return -1;
}
}
if (tmax < 0) return -1;
return tmin >= 0 ? tmin : tmax;
}
@@ -0,0 +1,100 @@
// Per-zone hitbox raycast. Hitboxes live in the character's local frame (feet at
// y=0, facing -Z). A world-space ray is transformed into local space (undo yaw +
// translation) and slab-tested against each zone box; nearest entry wins.
import { HitZone } from './types';
interface LocalBox {
min: [number, number, number];
max: [number, number, number];
zone: HitZone;
}
// Ordered head->legs; overlaps resolved by nearest t at test time.
const HITBOXES: LocalBox[] = [
{ min: [-0.14, 1.5, -0.16], max: [0.14, 1.79, 0.16], zone: HitZone.Head },
{ min: [-0.27, 1.12, -0.18], max: [0.27, 1.5, 0.18], zone: HitZone.Chest },
{ min: [-0.23, 0.82, -0.17], max: [0.23, 1.12, 0.17], zone: HitZone.Stomach },
{ min: [0.26, 0.9, -0.14], max: [0.46, 1.5, 0.14], zone: HitZone.Arm },
{ min: [-0.46, 0.9, -0.14], max: [-0.26, 1.5, 0.14], zone: HitZone.Arm },
{ min: [-0.24, 0.0, -0.17], max: [0.24, 0.82, 0.17], zone: HitZone.Leg },
];
export const CHAR_HEIGHT = 1.79;
export const CHAR_BOUND_R = 0.55; // broadphase radius (XZ)
export interface CharHit {
zone: HitZone;
t: number;
}
// Slab intersection of a ray with an axis-aligned box; returns entry t or -1.
function rayBox(
ox: number, oy: number, oz: number,
dx: number, dy: number, dz: number,
min: [number, number, number], max: [number, number, number],
): number {
let tmin = -Infinity;
let tmax = Infinity;
const o = [ox, oy, oz];
const d = [dx, dy, dz];
for (let a = 0; a < 3; a++) {
if (Math.abs(d[a]) < 1e-8) {
if (o[a] < min[a] || o[a] > max[a]) return -1;
} else {
const inv = 1 / d[a];
let t1 = (min[a] - o[a]) * inv;
let t2 = (max[a] - o[a]) * inv;
if (t1 > t2) {
const tmp = t1;
t1 = t2;
t2 = tmp;
}
if (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return -1;
}
}
if (tmax < 0) return -1;
return tmin >= 0 ? tmin : tmax;
}
// px,py,pz = character feet position; yaw = facing. Ray in world space with unit
// dir. Returns nearest zone hit within maxT, or null.
export function raycastCharacter(
px: number, py: number, pz: number, yaw: number,
ox: number, oy: number, oz: number,
dx: number, dy: number, dz: number,
maxT: number,
): CharHit | null {
// Broadphase: distance from ray to the character's vertical axis.
const rx = ox - px;
const rz = oz - pz;
// closest approach param along ray in XZ
const proj = -(rx * dx + rz * dz);
const cxp = rx + dx * Math.max(0, proj);
const czp = rz + dz * Math.max(0, proj);
if (cxp * cxp + czp * czp > CHAR_BOUND_R * CHAR_BOUND_R && proj > 0) {
// may still clip if origin very close; fall through only when clearly outside
if (proj > CHAR_BOUND_R) return null;
}
const c = Math.cos(yaw);
const s = Math.sin(yaw);
const ry = oy - py;
const lox = c * rx - s * rz;
const loz = s * rx + c * rz;
const loy = ry;
const ldx = c * dx - s * dz;
const ldz = s * dx + c * dz;
const ldy = dy;
let best: CharHit | null = null;
for (const box of HITBOXES) {
const t = rayBox(lox, loy, loz, ldx, ldy, ldz, box.min, box.max);
if (t >= 0 && t <= maxT && (!best || t < best.t)) {
best = { zone: box.zone, t };
}
}
return best;
}
@@ -0,0 +1,59 @@
// Central gameplay tunables.
export const FIXED_DT = 1 / 60; // physics step
export const MAX_FRAME_DT = 1 / 20; // clamp big frame gaps (tab switches)
export const GRAVITY = 20; // m/s^2
export const PLAYER = {
radius: 0.42,
height: 1.8,
eyeHeight: 1.62,
jumpSpeed: 7.4, // apex ~1.37m (clears 1.2m crates)
runSpeed: 6.6,
walkSpeed: 3.3, // hold shift: slow + accurate
groundAccel: 65,
airAccel: 14,
friction: 10,
maxHealth: 100,
maxArmor: 100,
};
export const AI = {
fovDeg: 105, // vision cone half-handled in code
viewRange: 70,
reactionTime: 0.22, // seconds before a spotted enemy is engaged
aimError: 0.05, // radians of aim jitter (skill)
aimSpeed: 7, // rad/s turn toward target
burstMin: 0.3,
burstMax: 0.9,
repathInterval: 0.6,
};
export const ROUND = {
freezeTime: 3,
roundTime: 115, // seconds before CT win by time (no plant)
bombTimer: 40,
plantTime: 3.2,
defuseTime: 7,
overTime: 5, // seconds showing result before next round
bombDamageRadius: 14,
};
// Team colors (three.js hex). CT = navy/blue, T = tan/sand.
export const COLORS = {
ctPrimary: 0x2f5fa8,
ctSecondary: 0xbcd2f0,
ctSkin: 0xd7a98a,
tPrimary: 0xb08b4f,
tSecondary: 0x5a4526,
tSkin: 0xc99a76,
wall: 0xb8a488,
wallDark: 0x8f7f66,
floor: 0xa79878,
crate: 0x9c6b34,
crateEdge: 0x5c3d1c,
siteA: 0xd25b4a,
siteB: 0x4aa3d2,
bomb: 0x222222,
sky: 0x8fb3d9,
};
@@ -0,0 +1,122 @@
// Procedural Dust2 core layout, authored as carved rectangles on the tile grid.
// Orientation (top-down): T spawn south (+Z, bottom), CT spawn north (-Z, top),
// A site / A long east (+X, right), B site / tunnels west (-X, left), mid runs
// vertically through the center with the passable doors halfway.
import { MapGrid, Region } from './grid';
export interface Crate {
x: number;
z: number;
hx: number; // half extent X (m)
hz: number; // half extent Z (m)
height: number; // top Y (m); box spans y in [0, height]
}
export interface BombSite {
name: 'A' | 'B';
region: Region;
center: { x: number; z: number };
radius: number;
}
export interface SpawnPoint {
x: number;
z: number;
}
export interface Dust2 {
grid: MapGrid;
crates: Crate[];
sites: { A: BombSite; B: BombSite };
tSpawns: SpawnPoint[];
ctSpawns: SpawnPoint[];
doorCells: { cx: number; cz: number }[];
bounds: { minX: number; maxX: number; minZ: number; maxZ: number };
}
export const CELL = 2.2;
export const COLS = 60;
export const ROWS = 60;
export const WALL_HEIGHT = 4.2;
export function buildDust2(): Dust2 {
const g = new MapGrid(COLS, ROWS, CELL);
// --- rooms & corridors (inclusive cell coords) ---
g.carve(22, 3, 34, 9, Region.CTSpawn); // CT spawn (north)
g.carve(34, 5, 41, 9, Region.Connect); // A short (CT -> A)
g.carve(40, 6, 54, 18, Region.ASite); // A site (NE)
g.carve(28, 9, 30, 46, Region.Mid); // mid corridor
g.carve(31, 16, 41, 19, Region.Cat); // catwalk (mid -> A)
g.carve(49, 18, 53, 50, Region.Long); // A long (right side)
g.carve(42, 49, 53, 53, Region.Connect); // T -> long doors
g.carve(30, 50, 44, 57, Region.TSpawn); // T spawn (south)
g.carve(26, 44, 34, 50, Region.Connect); // T -> mid junction
g.carve(8, 44, 33, 48, Region.Tunnel); // upper tunnels (T -> B)
g.carve(8, 20, 13, 48, Region.Tunnel); // B tunnel (vertical)
g.carve(4, 14, 18, 26, Region.BSite); // B site (W)
g.carve(13, 20, 28, 24, Region.Connect); // mid -> B (lower/doors)
// Mid doors: a passable door prop sits across these cells.
g.tag(28, 26, 30, 28, Region.Door);
const doorCells = [
{ cx: 28, cz: 27 },
{ cx: 29, cz: 27 },
{ cx: 30, cz: 27 },
];
// --- crates (cover + plant references) ---
const crateCell = (cx: number, cz: number, hx: number, hz: number, height: number): Crate => {
const c = g.centerOf(cx, cz);
return { x: c.x, z: c.z, hx, hz, height };
};
const crates: Crate[] = [
// A site
crateCell(45, 13, 1.5, 1.5, 1.5),
crateCell(48, 11, 1.5, 1.5, 1.5),
crateCell(43, 9, 2.0, 1.1, 1.0),
// B site
crateCell(9, 21, 1.5, 1.5, 1.5),
crateCell(13, 24, 1.5, 1.5, 1.5),
crateCell(7, 17, 1.1, 1.1, 1.0),
// mid / long / catwalk cover
crateCell(29, 20, 1.3, 1.3, 1.2),
crateCell(51, 34, 1.8, 1.3, 1.2),
crateCell(36, 18, 1.3, 1.3, 1.2),
];
// --- bomb sites ---
const aCenter = g.centerOf(47, 12);
const bCenter = g.centerOf(11, 20);
const sites = {
A: { name: 'A' as const, region: Region.ASite, center: aCenter, radius: 16 },
B: { name: 'B' as const, region: Region.BSite, center: bCenter, radius: 15 },
};
// --- spawns ---
const spawnAt = (cx: number, cz: number): SpawnPoint => g.centerOf(cx, cz);
const tSpawns = [
spawnAt(32, 54),
spawnAt(35, 55),
spawnAt(38, 54),
spawnAt(41, 55),
spawnAt(43, 52),
];
const ctSpawns = [
spawnAt(24, 5),
spawnAt(27, 6),
spawnAt(30, 5),
spawnAt(32, 7),
spawnAt(25, 7),
];
const bounds = {
minX: g.originX,
maxX: g.originX + COLS * CELL,
minZ: g.originZ,
maxZ: g.originZ + ROWS * CELL,
};
return { grid: g, crates, sites, tSpawns, ctSpawns, doorCells, bounds };
}
@@ -0,0 +1,118 @@
// Pooled transient visuals: bullet tracers, muzzle flashes, and impact sparks /
// blood puffs. All are cheap emissive/additive meshes recycled from fixed pools.
import * as THREE from 'three';
interface Slot {
mesh: THREE.Mesh;
life: number;
max: number;
}
export class Effects {
private tracers: Slot[] = [];
private flashes: Slot[] = [];
private impacts: Slot[] = [];
private scene: THREE.Scene;
constructor(scene: THREE.Scene) {
this.scene = scene;
const tracerGeo = new THREE.BoxGeometry(0.035, 0.035, 1);
for (let i = 0; i < 24; i++) {
const mesh = new THREE.Mesh(
tracerGeo,
new THREE.MeshBasicMaterial({ color: 0xffe08a, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false }),
);
mesh.visible = false;
scene.add(mesh);
this.tracers.push({ mesh, life: 0, max: 0.06 });
}
const flashGeo = new THREE.SphereGeometry(0.16, 6, 6);
for (let i = 0; i < 16; i++) {
const mesh = new THREE.Mesh(
flashGeo,
new THREE.MeshBasicMaterial({ color: 0xfff2b0, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false }),
);
mesh.visible = false;
scene.add(mesh);
this.flashes.push({ mesh, life: 0, max: 0.05 });
}
const impactGeo = new THREE.SphereGeometry(0.09, 6, 6);
for (let i = 0; i < 32; i++) {
const mesh = new THREE.Mesh(
impactGeo,
new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0, depthWrite: false }),
);
mesh.visible = false;
scene.add(mesh);
this.impacts.push({ mesh, life: 0, max: 0.18 });
}
}
private take(pool: Slot[]): Slot | null {
for (const s of pool) if (s.life <= 0) return s;
return pool[0] ?? null;
}
tracer(fx: number, fy: number, fz: number, tx: number, ty: number, tz: number): void {
const s = this.take(this.tracers);
if (!s) return;
const dx = tx - fx, dy = ty - fy, dz = tz - fz;
const len = Math.hypot(dx, dy, dz) || 0.01;
s.mesh.position.set((fx + tx) / 2, (fy + ty) / 2, (fz + tz) / 2);
s.mesh.scale.set(1, 1, len);
s.mesh.lookAt(tx, ty, tz);
s.mesh.visible = true;
(s.mesh.material as THREE.MeshBasicMaterial).opacity = 0.9;
s.life = s.max;
}
flash(x: number, y: number, z: number): void {
const s = this.take(this.flashes);
if (!s) return;
s.mesh.position.set(x, y, z);
const sc = 0.7 + Math.random() * 0.6;
s.mesh.scale.setScalar(sc);
s.mesh.visible = true;
(s.mesh.material as THREE.MeshBasicMaterial).opacity = 1;
s.life = s.max;
}
impact(x: number, y: number, z: number, blood: boolean): void {
const s = this.take(this.impacts);
if (!s) return;
s.mesh.position.set(x, y, z);
s.mesh.scale.setScalar(blood ? 1.4 : 1);
(s.mesh.material as THREE.MeshBasicMaterial).color.setHex(blood ? 0x9c1c1c : 0xd9cdb0);
s.mesh.visible = true;
(s.mesh.material as THREE.MeshBasicMaterial).opacity = 1;
s.life = s.max;
}
update(dt: number): void {
for (const s of this.tracers) {
if (s.life <= 0) continue;
s.life -= dt;
const mat = s.mesh.material as THREE.MeshBasicMaterial;
mat.opacity = Math.max(0, (s.life / s.max) * 0.9);
if (s.life <= 0) s.mesh.visible = false;
}
for (const s of this.flashes) {
if (s.life <= 0) continue;
s.life -= dt;
const mat = s.mesh.material as THREE.MeshBasicMaterial;
mat.opacity = Math.max(0, s.life / s.max);
if (s.life <= 0) s.mesh.visible = false;
}
for (const s of this.impacts) {
if (s.life <= 0) continue;
s.life -= dt;
const k = s.life / s.max;
const mat = s.mesh.material as THREE.MeshBasicMaterial;
mat.opacity = Math.max(0, k);
s.mesh.scale.multiplyScalar(1 + dt * 2);
s.mesh.position.y += dt * 0.4;
if (s.life <= 0) s.mesh.visible = false;
}
}
}
@@ -0,0 +1,151 @@
// Blocky humanoid built entirely from boxes: separate head, torso, pelvis, two
// arms (posed forward gripping the weapon) and two legs with knees + feet. No
// capsules/cylinders. CT reads blue with a helmet; T reads tan with a balaclava.
import * as THREE from 'three';
import { COLORS } from '../constants';
import { Team } from '../types';
export interface CharacterRig {
root: THREE.Group;
legL: THREE.Group;
legR: THREE.Group;
armL: THREE.Group;
armR: THREE.Group;
head: THREE.Group;
weaponHolder: THREE.Group;
materials: THREE.Material[];
team: Team;
}
function box(
w: number, h: number, d: number, color: number, mats: THREE.Material[],
): THREE.Mesh {
const geo = new THREE.BoxGeometry(w, h, d);
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.05, flatShading: true });
mats.push(mat);
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true;
return mesh;
}
export function buildCharacter(team: Team): CharacterRig {
const mats: THREE.Material[] = [];
const isCT = team === Team.CT;
const primary = isCT ? COLORS.ctPrimary : COLORS.tPrimary;
const secondary = isCT ? COLORS.ctSecondary : COLORS.tSecondary;
const skin = isCT ? COLORS.ctSkin : COLORS.tSkin;
const root = new THREE.Group();
// pelvis
const pelvis = box(0.34, 0.22, 0.22, secondary, mats);
pelvis.position.set(0, 0.94, 0);
root.add(pelvis);
// chest (torso) + vest overlay
const chest = box(0.44, 0.5, 0.26, primary, mats);
chest.position.set(0, 1.3, 0);
root.add(chest);
const vest = box(0.46, 0.34, 0.1, secondary, mats);
vest.position.set(0, 1.34, 0.12);
root.add(vest);
// shoulders
const shoulders = box(0.5, 0.14, 0.24, primary, mats);
shoulders.position.set(0, 1.5, 0);
root.add(shoulders);
// head group (neck pivot)
const head = new THREE.Group();
head.position.set(0, 1.57, 0);
root.add(head);
const neck = box(0.12, 0.08, 0.12, skin, mats);
neck.position.set(0, 0.0, 0);
head.add(neck);
const skull = box(0.24, 0.24, 0.24, isCT ? skin : COLORS.tSecondary, mats);
skull.position.set(0, 0.16, 0);
head.add(skull);
if (isCT) {
// helmet
const helmet = box(0.27, 0.13, 0.27, 0x24405f, mats);
helmet.position.set(0, 0.24, 0);
head.add(helmet);
// face strip
const face = box(0.2, 0.09, 0.02, 0x2a2a30, mats);
face.position.set(0, 0.14, 0.12);
head.add(face);
} else {
// balaclava eye strip (skin showing)
const eyes = box(0.2, 0.06, 0.02, skin, mats);
eyes.position.set(0, 0.18, 0.12);
head.add(eyes);
}
// helper to build a limb hanging from a pivot group
const makeLeg = (side: number): THREE.Group => {
const g = new THREE.Group();
g.position.set(0.12 * side, 0.9, 0);
const upper = box(0.17, 0.46, 0.19, primary, mats);
upper.position.set(0, -0.23, 0);
g.add(upper);
const lower = box(0.15, 0.44, 0.17, secondary, mats);
lower.position.set(0, -0.66, 0.01);
g.add(lower);
const foot = box(0.16, 0.1, 0.28, 0x2a2a2a, mats);
foot.position.set(0, -0.87, 0.06);
g.add(foot);
return g;
};
const legL = makeLeg(1);
const legR = makeLeg(-1);
root.add(legL, legR);
// arms posed forward to grip the weapon (pivot at shoulder)
const makeArm = (side: number): THREE.Group => {
const g = new THREE.Group();
g.position.set(0.26 * side, 1.46, 0);
const upper = box(0.13, 0.34, 0.14, primary, mats);
upper.position.set(0, -0.15, 0);
g.add(upper);
const lower = box(0.11, 0.3, 0.12, skin, mats);
lower.position.set(0, -0.42, 0);
g.add(lower);
const hand = box(0.1, 0.1, 0.12, 0x3a2f28, mats);
hand.position.set(0, -0.58, 0);
g.add(hand);
// rotate the whole arm forward + inward so hands meet at the gun
g.rotation.x = -1.35;
g.rotation.z = 0.28 * side;
return g;
};
const armL = makeArm(1);
const armR = makeArm(-1);
root.add(armL, armR);
// weapon attaches in front of the chest where the hands converge
const weaponHolder = new THREE.Group();
weaponHolder.position.set(0, 1.2, -0.36);
root.add(weaponHolder);
root.traverse((o) => {
o.castShadow = true;
});
return { root, legL, legR, armL, armR, head, weaponHolder, materials: mats, team };
}
// Walk cycle + idle bob; freezes into a fallen pose when dead.
export function animateCharacter(
rig: CharacterRig, speed: number, time: number, dead: boolean,
): void {
if (dead) return;
const stride = Math.min(1, speed / 6);
const freq = 8;
const swing = Math.sin(time * freq) * 0.6 * stride;
rig.legL.rotation.x = swing;
rig.legR.rotation.x = -swing;
// subtle arm counter-bob while keeping the aim pose
rig.armL.rotation.x = -1.35 + Math.sin(time * freq) * 0.05 * stride;
rig.armR.rotation.x = -1.35 - Math.sin(time * freq) * 0.05 * stride;
rig.root.position.y = Math.abs(Math.sin(time * freq)) * 0.04 * stride;
}
@@ -0,0 +1,132 @@
// Procedural weapon meshes. The same silhouette serves the third-person world
// model (parented to a character's hands) and the first-person viewmodel
// (parented to the camera). Barrels point down -Z; a `muzzle` empty marks the
// tip for flash + tracer spawning.
import * as THREE from 'three';
import { WeaponId } from '../types';
export interface WeaponMesh {
group: THREE.Group;
muzzle: THREE.Object3D;
}
function part(
g: THREE.Group, w: number, h: number, d: number, color: number,
x: number, y: number, z: number, rotX = 0, metal = 0.4, rough = 0.5,
): THREE.Mesh {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(w, h, d),
new THREE.MeshStandardMaterial({ color, metalness: metal, roughness: rough }),
);
mesh.position.set(x, y, z);
mesh.rotation.x = rotX;
mesh.castShadow = true;
g.add(mesh);
return mesh;
}
const BLACK = 0x1c1d1f;
const STEEL = 0x9aa0a6;
const WOOD = 0x6b4a2a;
const TAN = 0x8a7a55;
function buildRifle(id: WeaponId): WeaponMesh {
const g = new THREE.Group();
const accent = id === 'ak47' ? WOOD : TAN;
part(g, 0.07, 0.11, 0.52, BLACK, 0, 0, -0.03); // receiver
part(g, 0.032, 0.032, 0.5, BLACK, 0, 0.03, -0.4, 0, 0.7, 0.35); // barrel
part(g, 0.06, 0.2, 0.1, accent, 0, -0.14, 0.0, 0.25); // magazine
part(g, 0.05, 0.1, 0.24, accent, 0, -0.01, 0.24); // stock
part(g, 0.045, 0.13, 0.055, BLACK, 0, -0.1, 0.09, 0.15); // grip
part(g, 0.02, 0.04, 0.12, BLACK, 0, 0.075, -0.18); // front sight/handguard top
const muzzle = new THREE.Object3D();
muzzle.position.set(0, 0.03, -0.66);
g.add(muzzle);
return { group: g, muzzle };
}
function buildAwp(): WeaponMesh {
const g = new THREE.Group();
const green = 0x384231;
part(g, 0.07, 0.12, 0.72, green, 0, 0, 0.0);
part(g, 0.03, 0.03, 0.72, BLACK, 0, 0.02, -0.62, 0, 0.7, 0.35); // long barrel
part(g, 0.06, 0.13, 0.09, BLACK, 0, -0.11, -0.05, 0.1); // mag
part(g, 0.055, 0.11, 0.32, green, 0, -0.02, 0.36); // stock
part(g, 0.045, 0.14, 0.05, BLACK, 0, -0.1, 0.1, 0.1); // grip
// scope
part(g, 0.055, 0.055, 0.3, BLACK, 0, 0.12, -0.02, 0, 0.6, 0.3);
part(g, 0.02, 0.06, 0.02, BLACK, 0, 0.07, -0.14);
part(g, 0.02, 0.06, 0.02, BLACK, 0, 0.07, 0.1);
const lens = new THREE.Mesh(
new THREE.CircleGeometry(0.026, 12),
new THREE.MeshStandardMaterial({ color: 0x224466, metalness: 0.9, roughness: 0.1, emissive: 0x113355 }),
);
lens.position.set(0, 0.12, 0.14);
lens.rotation.y = Math.PI;
g.add(lens);
const muzzle = new THREE.Object3D();
muzzle.position.set(0, 0.02, -0.98);
g.add(muzzle);
return { group: g, muzzle };
}
function buildPistol(id: WeaponId): WeaponMesh {
const g = new THREE.Group();
const scale = id === 'deagle' ? 1.28 : 1.0;
const bodyColor = id === 'deagle' ? 0xb8a44e : id === 'usp' ? 0x26282b : BLACK;
const s = (v: number) => v * scale;
part(g, s(0.05), s(0.07), s(0.24), bodyColor, 0, s(0.02), s(-0.03), 0, 0.6, 0.35); // slide
part(g, s(0.045), s(0.17), s(0.08), 0x222327, 0, s(-0.1), s(0.05), 0.28); // grip
part(g, s(0.028), s(0.028), s(0.06), BLACK, 0, s(0.02), s(-0.16)); // barrel tip
let muzZ = s(-0.18);
if (id === 'usp') {
part(g, s(0.05), s(0.05), s(0.16), 0x2a2c2f, 0, s(0.02), s(-0.26), 0, 0.5, 0.4); // suppressor
muzZ = s(-0.36);
}
const muzzle = new THREE.Object3D();
muzzle.position.set(0, s(0.02), muzZ);
g.add(muzzle);
return { group: g, muzzle };
}
function buildKnife(): WeaponMesh {
const g = new THREE.Group();
part(g, 0.028, 0.045, 0.14, 0x1a1a1a, 0, -0.02, 0.07, 0.05); // handle
part(g, 0.012, 0.06, 0.24, STEEL, 0, 0.03, -0.12, -0.03, 0.95, 0.15); // blade
const muzzle = new THREE.Object3D();
muzzle.position.set(0, 0.03, -0.24);
g.add(muzzle);
return { group: g, muzzle };
}
export function buildWeaponMesh(id: WeaponId): WeaponMesh {
switch (id) {
case 'ak47':
case 'm4a4':
return buildRifle(id);
case 'awp':
return buildAwp();
case 'glock':
case 'usp':
case 'deagle':
return buildPistol(id);
case 'knife':
return buildKnife();
}
}
// Two simple gripping hands for the first-person viewmodel.
export function addViewHands(g: THREE.Group): void {
const skin = 0xc99a76;
const mk = (x: number, y: number, z: number) => {
const m = new THREE.Mesh(
new THREE.BoxGeometry(0.09, 0.09, 0.12),
new THREE.MeshStandardMaterial({ color: skin, roughness: 0.7 }),
);
m.position.set(x, y, z);
g.add(m);
};
mk(0.02, -0.08, 0.08); // rear hand at grip
mk(0.0, -0.05, -0.16); // front hand on handguard
}
@@ -0,0 +1,233 @@
// Tile-grid map core. Pure TS (no three.js) so it can be unit-tested with bun.
// The world is a grid of square cells in the XZ plane; walls are full-height
// columns, floor cells are walkable. Line-of-sight and bullet clipping against
// walls use grid DDA traversal (Amanatides & Woo).
export enum Region {
None = 0,
TSpawn = 1,
CTSpawn = 2,
ASite = 3,
BSite = 4,
Mid = 5,
Long = 6,
Cat = 7,
Tunnel = 8,
Connect = 9,
Door = 10,
}
const REGION_CHAR: Record<number, string> = {
0: '#', // wall / void rendered as # in ascii dumps
1: 't',
2: 'c',
3: 'A',
4: 'B',
5: 'm',
6: 'L',
7: 'k',
8: 'u',
9: '+',
10: 'D',
};
export interface CellRef {
cx: number;
cz: number;
}
export class MapGrid {
readonly cols: number;
readonly rows: number;
readonly cell: number; // world size of one cell (meters)
readonly floor: Uint8Array; // 1 = walkable
readonly region: Uint8Array; // Region id per cell (only meaningful when floor)
readonly originX: number; // world X of cell (0,0) left edge
readonly originZ: number;
constructor(cols: number, rows: number, cell: number) {
this.cols = cols;
this.rows = rows;
this.cell = cell;
this.floor = new Uint8Array(cols * rows);
this.region = new Uint8Array(cols * rows);
// Center the grid on the world origin.
this.originX = -(cols * cell) / 2;
this.originZ = -(rows * cell) / 2;
}
idx(cx: number, cz: number): number {
return cz * this.cols + cx;
}
inBounds(cx: number, cz: number): boolean {
return cx >= 0 && cz >= 0 && cx < this.cols && cz < this.rows;
}
isFloor(cx: number, cz: number): boolean {
if (!this.inBounds(cx, cz)) return false;
return this.floor[this.idx(cx, cz)] === 1;
}
// Out-of-bounds counts as wall so the arena is sealed.
isWall(cx: number, cz: number): boolean {
return !this.isFloor(cx, cz);
}
regionAt(cx: number, cz: number): Region {
if (!this.inBounds(cx, cz)) return Region.None;
return this.region[this.idx(cx, cz)] as Region;
}
// Carve a filled rectangle of floor (inclusive cell coords) with a region tag.
carve(cx0: number, cz0: number, cx1: number, cz1: number, region: Region): void {
const x0 = Math.min(cx0, cx1);
const x1 = Math.max(cx0, cx1);
const z0 = Math.min(cz0, cz1);
const z1 = Math.max(cz0, cz1);
for (let cz = z0; cz <= z1; cz++) {
for (let cx = x0; cx <= x1; cx++) {
if (!this.inBounds(cx, cz)) continue;
const i = this.idx(cx, cz);
this.floor[i] = 1;
this.region[i] = region;
}
}
}
// Overwrite region on already-carved floor cells (does not create floor).
tag(cx0: number, cz0: number, cx1: number, cz1: number, region: Region): void {
const x0 = Math.min(cx0, cx1);
const x1 = Math.max(cx0, cx1);
const z0 = Math.min(cz0, cz1);
const z1 = Math.max(cz0, cz1);
for (let cz = z0; cz <= z1; cz++) {
for (let cx = x0; cx <= x1; cx++) {
if (!this.inBounds(cx, cz)) continue;
const i = this.idx(cx, cz);
if (this.floor[i]) this.region[i] = region;
}
}
}
// --- world <-> cell conversion ---
cellOf(x: number, z: number): CellRef {
return {
cx: Math.floor((x - this.originX) / this.cell),
cz: Math.floor((z - this.originZ) / this.cell),
};
}
centerOf(cx: number, cz: number): { x: number; z: number } {
return {
x: this.originX + (cx + 0.5) * this.cell,
z: this.originZ + (cz + 0.5) * this.cell,
};
}
isWallAtWorld(x: number, z: number): boolean {
const c = this.cellOf(x, z);
return this.isWall(c.cx, c.cz);
}
// DDA: distance (world units) along ray (ox,oz)+t*(dx,dz) to first wall cell.
// Returns maxDist if nothing hit within range. dir need not be normalized;
// dx,dz are treated as a unit direction here.
rayWallDistance(ox: number, oz: number, dx: number, dz: number, maxDist: number): number {
const cs = this.cell;
let cx = Math.floor((ox - this.originX) / cs);
let cz = Math.floor((oz - this.originZ) / cs);
if (this.isWall(cx, cz)) return 0;
const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0;
const stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0;
const tDeltaX = dx !== 0 ? Math.abs(cs / dx) : Infinity;
const tDeltaZ = dz !== 0 ? Math.abs(cs / dz) : Infinity;
// world coords of current cell edges
const cellMinX = this.originX + cx * cs;
const cellMinZ = this.originZ + cz * cs;
let tMaxX =
dx !== 0
? (stepX > 0 ? cellMinX + cs - ox : ox - cellMinX) / Math.abs(dx)
: Infinity;
let tMaxZ =
dz !== 0
? (stepZ > 0 ? cellMinZ + cs - oz : oz - cellMinZ) / Math.abs(dz)
: Infinity;
let guard = 0;
while (guard++ < 4096) {
let t: number;
if (tMaxX < tMaxZ) {
cx += stepX;
t = tMaxX;
tMaxX += tDeltaX;
} else {
cz += stepZ;
t = tMaxZ;
tMaxZ += tDeltaZ;
}
if (t > maxDist) return maxDist;
if (this.isWall(cx, cz)) return t;
}
return maxDist;
}
// True if a wall blocks the straight segment a->b in the XZ plane.
segmentBlocked(ax: number, az: number, bx: number, bz: number): boolean {
const dx = bx - ax;
const dz = bz - az;
const len = Math.hypot(dx, dz);
if (len < 1e-6) return false;
const d = this.rayWallDistance(ax, az, dx / len, dz / len, len);
return d < len - 1e-3;
}
// Breadth-first reachable set from a seed floor cell; used to verify the map
// is fully connected during authoring.
reachableCount(seedCx: number, seedCz: number): number {
const seen = new Uint8Array(this.cols * this.rows);
const stack: number[] = [];
if (!this.isFloor(seedCx, seedCz)) return 0;
stack.push(this.idx(seedCx, seedCz));
seen[this.idx(seedCx, seedCz)] = 1;
let count = 0;
while (stack.length) {
const i = stack.pop()!;
count++;
const cx = i % this.cols;
const cz = (i - cx) / this.cols;
const neigh = [
[cx + 1, cz],
[cx - 1, cz],
[cx, cz + 1],
[cx, cz - 1],
];
for (const [nx, nz] of neigh) {
if (!this.isFloor(nx, nz)) continue;
const ni = this.idx(nx, nz);
if (seen[ni]) continue;
seen[ni] = 1;
stack.push(ni);
}
}
return count;
}
totalFloor(): number {
let n = 0;
for (let i = 0; i < this.floor.length; i++) if (this.floor[i]) n++;
return n;
}
toAscii(): string {
const lines: string[] = [];
for (let cz = 0; cz < this.rows; cz++) {
let line = '';
for (let cx = 0; cx < this.cols; cx++) {
const i = this.idx(cx, cz);
line += this.floor[i] ? REGION_CHAR[this.region[i]] ?? '.' : '#';
}
lines.push(line);
}
return lines.join('\n');
}
}
@@ -0,0 +1,116 @@
// Keyboard + mouse + pointer-lock input. Movement/aim are polled each frame;
// discrete actions (weapon switch, reload, jump) use edge-triggered presses.
export class Input {
keys = new Set<string>();
private justPressed = new Set<string>();
mouseDX = 0;
mouseDY = 0;
left = false;
right = false;
leftJustPressed = false;
wheel = 0;
locked = false;
private el: HTMLElement;
private handlers: Array<[EventTarget, string, EventListenerOrEventListenerObject]> = [];
constructor(el: HTMLElement) {
this.el = el;
const gameKeys = new Set([
'KeyW', 'KeyA', 'KeyS', 'KeyD', 'Space', 'ShiftLeft', 'KeyR', 'KeyE', 'KeyG',
'KeyF', 'Digit1', 'Digit2', 'Digit3', 'Tab', 'KeyB',
]);
const onKeyDown = (e: KeyboardEvent) => {
if (gameKeys.has(e.code)) e.preventDefault();
if (!e.repeat && !this.keys.has(e.code)) this.justPressed.add(e.code);
this.keys.add(e.code);
};
const onKeyUp = (e: KeyboardEvent) => {
this.keys.delete(e.code);
};
const onMouseDown = (e: MouseEvent) => {
if (!this.locked) return;
if (e.button === 0) {
this.left = true;
this.leftJustPressed = true;
} else if (e.button === 2) {
this.right = true;
}
};
const onMouseUp = (e: MouseEvent) => {
if (e.button === 0) this.left = false;
else if (e.button === 2) this.right = false;
};
const onMouseMove = (e: MouseEvent) => {
if (!this.locked) return;
this.mouseDX += e.movementX;
this.mouseDY += e.movementY;
};
const onContext = (e: Event) => e.preventDefault();
const onWheel = (e: WheelEvent) => {
if (this.locked) {
e.preventDefault();
this.wheel += Math.sign(e.deltaY);
}
};
const onLockChange = () => {
this.locked = document.pointerLockElement === this.el;
if (!this.locked) {
this.keys.clear();
this.left = this.right = false;
}
};
this.add(window, 'keydown', onKeyDown as EventListener);
this.add(window, 'keyup', onKeyUp as EventListener);
this.add(window, 'mousedown', onMouseDown as EventListener);
this.add(window, 'mouseup', onMouseUp as EventListener);
this.add(window, 'mousemove', onMouseMove as EventListener);
this.add(el, 'contextmenu', onContext);
this.add(el, 'wheel', onWheel as EventListener, { passive: false } as AddEventListenerOptions);
this.add(document, 'pointerlockchange', onLockChange);
}
private add(
target: EventTarget, type: string, fn: EventListenerOrEventListenerObject,
opts?: AddEventListenerOptions,
): void {
target.addEventListener(type, fn, opts);
this.handlers.push([target, type, fn]);
}
requestLock(): void {
// Pointer lock can be unavailable (sandboxed iframe, unfocused document).
// Swallow both sync throws and promise rejections; keyboard control still
// works and the game keeps running in that degraded mode.
try {
const r = this.el.requestPointerLock?.() as unknown as Promise<void> | undefined;
if (r && typeof r.catch === 'function') r.catch(() => {});
} catch {
/* ignore */
}
}
down(code: string): boolean {
return this.keys.has(code);
}
pressed(code: string): boolean {
return this.justPressed.has(code);
}
// call at end of each frame after inputs consumed
endFrame(): void {
this.justPressed.clear();
this.leftJustPressed = false;
this.mouseDX = 0;
this.mouseDY = 0;
this.wheel = 0;
}
dispose(): void {
for (const [t, type, fn] of this.handlers) t.removeEventListener(type, fn);
this.handlers = [];
}
}
@@ -0,0 +1,56 @@
// Small math helpers. Yaw convention matches a three.js camera with
// rotation.order 'YXZ': at yaw = 0 the facing direction is -Z.
export function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function rand(lo: number, hi: number): number {
return lo + Math.random() * (hi - lo);
}
export function randInt(lo: number, hi: number): number {
return Math.floor(rand(lo, hi + 1));
}
export function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
// Facing direction for a yaw (XZ plane), matching camera base-forward -Z.
export function forwardFromYaw(yaw: number): { x: number; z: number } {
return { x: -Math.sin(yaw), z: -Math.cos(yaw) };
}
// Inverse of forwardFromYaw.
export function yawFromDir(x: number, z: number): number {
return Math.atan2(-x, -z);
}
// Shortest signed angular difference a->b in (-PI, PI].
export function angleDelta(a: number, b: number): number {
let d = (b - a) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
return d;
}
export function moveAngleToward(from: number, to: number, maxStep: number): number {
const d = angleDelta(from, to);
if (Math.abs(d) <= maxStep) return to;
return from + Math.sign(d) * maxStep;
}
export function dist2(ax: number, az: number, bx: number, bz: number): number {
const dx = ax - bx;
const dz = az - bz;
return dx * dx + dz * dz;
}
export function len2(x: number, z: number): number {
return Math.hypot(x, z);
}
@@ -0,0 +1,163 @@
// A* pathfinding over the floor grid for AI navigation. 8-connected with no
// corner-cutting through walls; the resulting cell path is line-of-sight
// simplified so bots walk natural diagonals instead of hugging cell centers.
import { MapGrid } from './grid';
interface HeapItem {
i: number;
f: number;
}
class MinHeap {
private a: HeapItem[] = [];
get size(): number {
return this.a.length;
}
push(item: HeapItem): void {
const a = this.a;
a.push(item);
let c = a.length - 1;
while (c > 0) {
const p = (c - 1) >> 1;
if (a[p].f <= a[c].f) break;
[a[p], a[c]] = [a[c], a[p]];
c = p;
}
}
pop(): HeapItem {
const a = this.a;
const top = a[0];
const last = a.pop()!;
if (a.length) {
a[0] = last;
let p = 0;
const n = a.length;
for (;;) {
const l = p * 2 + 1;
const r = l + 1;
let s = p;
if (l < n && a[l].f < a[s].f) s = l;
if (r < n && a[r].f < a[s].f) s = r;
if (s === p) break;
[a[p], a[s]] = [a[s], a[p]];
p = s;
}
}
return top;
}
}
function nearestFloor(grid: MapGrid, cx: number, cz: number): { cx: number; cz: number } | null {
if (grid.isFloor(cx, cz)) return { cx, cz };
for (let r = 1; r <= 4; r++) {
for (let dz = -r; dz <= r; dz++) {
for (let dx = -r; dx <= r; dx++) {
if (Math.abs(dx) !== r && Math.abs(dz) !== r) continue;
if (grid.isFloor(cx + dx, cz + dz)) return { cx: cx + dx, cz: cz + dz };
}
}
}
return null;
}
const SQRT2 = Math.SQRT2;
export function findPath(
grid: MapGrid,
startX: number, startZ: number,
goalX: number, goalZ: number,
): { x: number; z: number }[] | null {
const s0 = grid.cellOf(startX, startZ);
const g0 = grid.cellOf(goalX, goalZ);
const start = nearestFloor(grid, s0.cx, s0.cz);
const goal = nearestFloor(grid, g0.cx, g0.cz);
if (!start || !goal) return null;
const cols = grid.cols;
const n = cols * grid.rows;
const startI = start.cz * cols + start.cx;
const goalI = goal.cz * cols + goal.cx;
if (startI === goalI) return [{ x: goalX, z: goalZ }];
const gScore = new Float32Array(n).fill(Infinity);
const came = new Int32Array(n).fill(-1);
const closed = new Uint8Array(n);
const open = new MinHeap();
gScore[startI] = 0;
open.push({ i: startI, f: 0 });
const gcx = goal.cx;
const gcz = goal.cz;
const heur = (cx: number, cz: number): number => {
const dx = Math.abs(cx - gcx);
const dz = Math.abs(cz - gcz);
return (dx + dz) + (SQRT2 - 2) * Math.min(dx, dz);
};
let guard = 0;
let found = false;
while (open.size && guard++ < 20000) {
const cur = open.pop();
const ci = cur.i;
if (closed[ci]) continue;
closed[ci] = 1;
if (ci === goalI) {
found = true;
break;
}
const cx = ci % cols;
const cz = (ci - cx) / cols;
for (let dz = -1; dz <= 1; dz++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dz === 0) continue;
const nx = cx + dx;
const nz = cz + dz;
if (!grid.isFloor(nx, nz)) continue;
if (dx !== 0 && dz !== 0) {
// no corner cutting
if (grid.isWall(cx + dx, cz) || grid.isWall(cx, cz + dz)) continue;
}
const ni = nz * cols + nx;
if (closed[ni]) continue;
const step = dx !== 0 && dz !== 0 ? SQRT2 : 1;
const ng = gScore[ci] + step;
if (ng < gScore[ni]) {
gScore[ni] = ng;
came[ni] = ci;
open.push({ i: ni, f: ng + heur(nx, nz) });
}
}
}
}
if (!found) return null;
// reconstruct cell path
const cells: number[] = [];
let c = goalI;
while (c !== -1) {
cells.push(c);
c = came[c];
}
cells.reverse();
// to world points (cell centers), keep true goal as final point
const pts: { x: number; z: number }[] = cells.map((i) => {
const cx = i % cols;
const cz = (i - cx) / cols;
return grid.centerOf(cx, cz);
});
pts[pts.length - 1] = { x: goalX, z: goalZ };
// line-of-sight simplification (string pulling)
const out: { x: number; z: number }[] = [pts[0]];
let anchor = 0;
for (let i = 2; i < pts.length; i++) {
if (grid.segmentBlocked(pts[anchor].x, pts[anchor].z, pts[i].x, pts[i].z)) {
out.push(pts[i - 1]);
anchor = i - 1;
}
}
out.push(pts[pts.length - 1]);
return out;
}
@@ -0,0 +1,142 @@
// Player controller: turns raw input into the same intent fields the AI uses,
// plus first-person-only concerns (mouse look, scope hold, weapon switching,
// C4 plant/defuse/pickup and a buy-swap for trying the AWP).
import { PLAYER } from './constants';
import { forwardFromYaw, clamp } from './mathUtils';
import { BombState, Team, WeaponId, WeaponSlot } from './types';
import { WEAPONS } from './weapons';
import type { Actor, GameWorld } from './world';
const SENS = 0.0022;
const PITCH_LIMIT = 1.45;
function look(a: Actor, world: GameWorld): void {
const input = world.input;
if (!input.locked) return;
a.yaw -= input.mouseDX * SENS;
a.pitch -= input.mouseDY * SENS;
a.pitch = clamp(a.pitch, -PITCH_LIMIT, PITCH_LIMIT);
}
function equipSlot(world: GameWorld, a: Actor, slot: WeaponSlot): void {
const id = a.weapons.find((w) => WEAPONS[w].slot === slot);
if (id) world.equip(a, id);
}
function cycleWeapon(world: GameWorld, a: Actor, dir: number): void {
const idx = a.weapons.indexOf(a.current);
const next = (idx + (dir > 0 ? 1 : -1) + a.weapons.length) % a.weapons.length;
world.equip(a, a.weapons[next]);
}
function swapPrimary(world: GameWorld, a: Actor): void {
const rifle: WeaponId = a.team === Team.T ? 'ak47' : 'm4a4';
const primaryIdx = a.weapons.findIndex((w) => WEAPONS[w].slot === 'primary');
if (primaryIdx < 0) return;
const cur = a.weapons[primaryIdx];
const next: WeaponId = cur === 'awp' ? rifle : 'awp';
a.weapons[primaryIdx] = next;
const def = WEAPONS[next];
a.ammo[next] = { mag: def.magSize, reserve: def.reserve };
world.equip(a, next);
}
export function updatePlayer(world: GameWorld, a: Actor, dt: number, canMove: boolean): void {
const input = world.input;
if (!a.alive) {
updateSpectate(world);
return;
}
a.action = 'none';
look(a, world);
// movement intents
if (canMove) {
const fwd = forwardFromYaw(a.yaw);
const right = { x: -fwd.z, z: fwd.x };
let mx = 0, mz = 0;
if (input.down('KeyW')) { mx += fwd.x; mz += fwd.z; }
if (input.down('KeyS')) { mx -= fwd.x; mz -= fwd.z; }
if (input.down('KeyD')) { mx += right.x; mz += right.z; }
if (input.down('KeyA')) { mx -= right.x; mz -= right.z; }
a.moveX = mx;
a.moveZ = mz;
a.wantRun = !input.down('ShiftLeft');
if (input.down('Space')) a.wantJump = true;
} else {
a.moveX = a.moveZ = 0;
}
// weapon selection
if (input.pressed('Digit1')) equipSlot(world, a, 'primary');
if (input.pressed('Digit2')) equipSlot(world, a, 'secondary');
if (input.pressed('Digit3')) equipSlot(world, a, 'melee');
if (input.wheel !== 0) cycleWeapon(world, a, input.wheel);
if (input.pressed('KeyB')) swapPrimary(world, a);
if (input.pressed('KeyR')) world.startReload(a);
const w = WEAPONS[a.current];
// scope (hold right mouse on AWP)
if (w.scoped) {
const want = input.right;
if (want !== a.scoped) {
a.scoped = want;
world.audio.scope(want);
}
} else {
a.scoped = false;
}
// fire
if (w.automatic) {
if (input.left) world.fireWeapon(a);
} else if (input.leftJustPressed) {
world.fireWeapon(a);
}
// bomb interactions
handleBomb(world, a, canMove);
}
// While dead: cycle through living teammates' viewpoints and take control of one.
function updateSpectate(world: GameWorld): void {
const input = world.input;
world.ensureSpectate();
if (input.leftJustPressed || input.pressed('KeyD')) world.spectateCycle(1);
if (input.pressed('KeyA')) world.spectateCycle(-1);
if (input.pressed('KeyF') || input.pressed('Space')) world.takeOver();
}
function handleBomb(world: GameWorld, a: Actor, canMove: boolean): void {
const input = world.input;
const bomb = world.bombInfo();
let hint = '';
if (a.team === Team.T) {
if (a.hasBomb && world.isInSite(a) && a.grounded) {
hint = 'Hold [E] to plant the bomb';
if (input.down('KeyE')) a.action = 'plant';
} else if (bomb.state === BombState.Dropped) {
const d2 = (a.x - bomb.x) ** 2 + (a.z - bomb.z) ** 2;
if (d2 < 2.4 * 2.4) {
hint = 'Press [E] to pick up the bomb';
if (input.down('KeyE')) world.pickupBomb(a);
}
} else if (a.hasBomb) {
hint = 'Bring the bomb to site A or B';
}
} else {
if (bomb.state === BombState.Planted) {
const d2 = (a.x - bomb.x) ** 2 + (a.z - bomb.z) ** 2;
if (d2 < 2.3 * 2.3 && a.grounded) {
hint = 'Hold [E] to defuse';
if (input.down('KeyE')) a.action = 'defuse';
}
}
}
world.setHint(hint);
}
@@ -0,0 +1,142 @@
// Static world geometry: sky/fog, lights, floor, instanced walls (only cells
// bordering walkable space), crates, bomb-site markers and the passable mid
// doors. Returns the scene plus a few references the simulation needs.
import * as THREE from 'three';
import { COLORS } from './constants';
import { Dust2, WALL_HEIGHT } from './dust2';
import { Region } from './grid';
export interface SceneRefs {
scene: THREE.Scene;
sun: THREE.DirectionalLight;
}
function labelTexture(letter: string, color: string): THREE.CanvasTexture {
const c = document.createElement('canvas');
c.width = c.height = 128;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, 128, 128);
ctx.fillStyle = color;
ctx.font = 'bold 110px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(letter, 64, 70);
const tex = new THREE.CanvasTexture(c);
tex.anisotropy = 4;
return tex;
}
export function buildScene(d: Dust2): SceneRefs {
const scene = new THREE.Scene();
scene.background = new THREE.Color(COLORS.sky);
scene.fog = new THREE.Fog(COLORS.sky, 60, 170);
// lighting
const hemi = new THREE.HemisphereLight(0xdfeaf5, 0x6b5f47, 0.9);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2d8, 1.1);
sun.position.set(50, 90, 30);
scene.add(sun);
const amb = new THREE.AmbientLight(0xffffff, 0.18);
scene.add(amb);
const g = d.grid;
const { minX, maxX, minZ, maxZ } = d.bounds;
// floor
const floorGeo = new THREE.PlaneGeometry(maxX - minX, maxZ - minZ);
const floorMat = new THREE.MeshStandardMaterial({ color: COLORS.floor, roughness: 0.97, metalness: 0 });
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.set((minX + maxX) / 2, 0, (minZ + maxZ) / 2);
scene.add(floor);
// walls: only cells that border a floor cell are visible
const borderCells: { cx: number; cz: number }[] = [];
for (let cz = 0; cz < g.rows; cz++) {
for (let cx = 0; cx < g.cols; cx++) {
if (!g.isWall(cx, cz)) continue;
if (
g.isFloor(cx + 1, cz) || g.isFloor(cx - 1, cz) ||
g.isFloor(cx, cz + 1) || g.isFloor(cx, cz - 1)
) {
borderCells.push({ cx, cz });
}
}
}
const wallGeo = new THREE.BoxGeometry(g.cell, WALL_HEIGHT, g.cell);
// Per-instance tint comes from setColorAt (instanceColor); no vertexColors flag
// (the box geometry has no color attribute, which would render black).
const wallMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.95, metalness: 0.02, flatShading: true });
const walls = new THREE.InstancedMesh(wallGeo, wallMat, borderCells.length);
const m = new THREE.Matrix4();
const col = new THREE.Color();
const base = new THREE.Color(COLORS.wall);
const dark = new THREE.Color(COLORS.wallDark);
for (let i = 0; i < borderCells.length; i++) {
const { cx, cz } = borderCells[i];
const c = g.centerOf(cx, cz);
m.makeTranslation(c.x, WALL_HEIGHT / 2, c.z);
walls.setMatrixAt(i, m);
col.copy(base).lerp(dark, Math.random() * 0.6);
walls.setColorAt(i, col);
}
walls.instanceMatrix.needsUpdate = true;
if (walls.instanceColor) walls.instanceColor.needsUpdate = true;
scene.add(walls);
// crates
for (const cr of d.crates) {
const geo = new THREE.BoxGeometry(cr.hx * 2, cr.height, cr.hz * 2);
const mat = new THREE.MeshStandardMaterial({ color: COLORS.crate, roughness: 0.8, metalness: 0.05, flatShading: true });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(cr.x, cr.height / 2, cr.z);
scene.add(mesh);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(geo),
new THREE.LineBasicMaterial({ color: COLORS.crateEdge }),
);
edges.position.copy(mesh.position);
scene.add(edges);
}
// bomb-site markers
const marker = (x: number, z: number, letter: string, color: number, hex: string) => {
const ring = new THREE.Mesh(
new THREE.RingGeometry(5.2, 6, 40),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.5, side: THREE.DoubleSide }),
);
ring.rotation.x = -Math.PI / 2;
ring.position.set(x, 0.05, z);
scene.add(ring);
const lbl = new THREE.Mesh(
new THREE.PlaneGeometry(4, 4),
new THREE.MeshBasicMaterial({ map: labelTexture(letter, hex), transparent: true, opacity: 0.7 }),
);
lbl.rotation.x = -Math.PI / 2;
lbl.position.set(x, 0.06, z);
scene.add(lbl);
};
marker(d.sites.A.center.x, d.sites.A.center.z, 'A', COLORS.siteA, '#e0745f');
marker(d.sites.B.center.x, d.sites.B.center.z, 'B', COLORS.siteB, '#5fb4e0');
// passable mid doors (visual only, no collision)
for (const dc of d.doorCells) {
const c = g.centerOf(dc.cx, dc.cz);
const door = new THREE.Mesh(
new THREE.BoxGeometry(g.cell * 0.96, 2.6, 0.12),
new THREE.MeshStandardMaterial({ color: 0x7a6a4a, roughness: 0.7, metalness: 0.1, transparent: true, opacity: 0.92 }),
);
door.position.set(c.x, 1.3, c.z);
scene.add(door);
const frame = new THREE.Mesh(
new THREE.BoxGeometry(g.cell * 0.98, 2.9, 0.16),
new THREE.MeshStandardMaterial({ color: 0x4a3f2c, roughness: 0.8 }),
);
frame.position.set(c.x, 1.45, c.z);
scene.add(frame);
}
return { scene, sun };
}
@@ -0,0 +1,107 @@
// Minimal external store bridging the imperative engine to React HUD via
// useSyncExternalStore. The engine mutates through set(); the snapshot identity
// changes on each flush so React re-renders.
import { KillEvent, RoundPhase, Team } from './types';
export interface HudState {
started: boolean;
playerTeam: Team;
phase: RoundPhase;
roundNum: number;
scoreT: number;
scoreCT: number;
roundTime: number;
aliveT: number;
aliveCT: number;
alive: boolean;
spectating: boolean;
spectateName: string;
health: number;
armor: number;
helmet: boolean;
weaponName: string;
weaponId: string;
mag: number;
reserve: number;
infiniteAmmo: boolean;
reloading: boolean;
scoped: boolean;
spread: number; // radians, drives crosshair gap
hasBomb: boolean;
bombPlanted: boolean;
bombTimer: number;
defusing: boolean;
defuseProgress: number;
planting: boolean;
plantProgress: number;
hint: string;
banner: string;
killfeed: KillEvent[];
loadout: { id: string; name: string; slot: string; mag: number; reserve: number; current: boolean }[];
}
const initial: HudState = {
started: false,
playerTeam: Team.T,
phase: RoundPhase.Warmup,
roundNum: 0,
scoreT: 0,
scoreCT: 0,
roundTime: 0,
aliveT: 5,
aliveCT: 5,
alive: true,
spectating: false,
spectateName: '',
health: 100,
armor: 0,
helmet: false,
weaponName: '',
weaponId: '',
mag: 0,
reserve: 0,
infiniteAmmo: false,
reloading: false,
scoped: false,
spread: 0.02,
hasBomb: false,
bombPlanted: false,
bombTimer: 0,
defusing: false,
defuseProgress: 0,
planting: false,
plantProgress: 0,
hint: '',
banner: '',
killfeed: [],
loadout: [],
};
class HudStore {
private state: HudState = initial;
private listeners = new Set<() => void>();
subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getSnapshot = (): HudState => this.state;
set(partial: Partial<HudState>): void {
this.state = { ...this.state, ...partial };
for (const fn of this.listeners) fn();
}
reset(): void {
this.set({ ...initial });
}
}
export const hud = new HudStore();
@@ -0,0 +1,51 @@
// Shared enums / interfaces used across engine + UI.
export enum Team {
T = 0,
CT = 1,
}
export enum HitZone {
Head = 0,
Chest = 1,
Stomach = 2,
Arm = 3,
Leg = 4,
}
export type WeaponSlot = 'primary' | 'secondary' | 'melee';
export type WeaponId =
| 'ak47'
| 'm4a4'
| 'awp'
| 'glock'
| 'usp'
| 'deagle'
| 'knife';
export enum RoundPhase {
Warmup = 0,
Freeze = 1,
Live = 2,
Over = 3,
}
export enum BombState {
Carried = 0,
Dropped = 1,
Planted = 2,
Defused = 3,
Exploded = 4,
}
export interface KillEvent {
id: number;
attacker: string;
victim: string;
attackerTeam: Team;
victimTeam: Team;
weapon: WeaponId;
headshot: boolean;
time: number;
}
@@ -0,0 +1,118 @@
// Weapon data table + per-zone damage. Extensible: add an entry to WEAPONS and
// a matching viewmodel/worldmodel builder in entities/weaponModels.ts.
import { HitZone, WeaponId, WeaponSlot } from './types';
export interface WeaponDef {
id: WeaponId;
name: string;
slot: WeaponSlot;
damage: number; // base, to chest, no armor
armorPen: number; // fraction of damage kept vs armor (0..1)
fireInterval: number; // seconds between shots
automatic: boolean;
magSize: number;
reserve: number;
reloadTime: number;
range: number; // effective hitscan range (m)
// recoil / accuracy (radians)
recoilPitch: number; // upward kick per shot
recoilYaw: number; // max horizontal kick per shot (± random)
spreadPerShot: number; // inaccuracy added per shot
baseSpread: number; // standing inaccuracy
maxSpread: number;
recoverRate: number; // spread recovered per second
moveSpeedMul: number; // movement multiplier while held
scoped?: boolean; // AWP: right-click 3D zoom + scope mask
scopedFov?: number;
isKnife?: boolean;
}
// Head is 2x body per the spec; other zones scale off chest.
export const ZONE_MULT: Record<HitZone, number> = {
[HitZone.Head]: 2.0,
[HitZone.Chest]: 1.0,
[HitZone.Stomach]: 1.25,
[HitZone.Arm]: 0.75,
[HitZone.Leg]: 0.75,
};
export const WEAPONS: Record<WeaponId, WeaponDef> = {
ak47: {
id: 'ak47', name: 'AK-47', slot: 'primary',
damage: 36, armorPen: 0.775, fireInterval: 0.1, automatic: true,
magSize: 30, reserve: 90, reloadTime: 2.4, range: 90,
recoilPitch: 0.019, recoilYaw: 0.011, spreadPerShot: 0.013,
baseSpread: 0.006, maxSpread: 0.14, recoverRate: 0.5, moveSpeedMul: 0.92,
},
m4a4: {
id: 'm4a4', name: 'M4A4', slot: 'primary',
damage: 33, armorPen: 0.7, fireInterval: 0.09, automatic: true,
magSize: 30, reserve: 90, reloadTime: 3.1, range: 90,
recoilPitch: 0.011, recoilYaw: 0.006, spreadPerShot: 0.008,
baseSpread: 0.005, maxSpread: 0.1, recoverRate: 0.55, moveSpeedMul: 0.94,
},
awp: {
id: 'awp', name: 'AWP', slot: 'primary',
damage: 115, armorPen: 0.975, fireInterval: 1.5, automatic: false,
magSize: 10, reserve: 30, reloadTime: 3.7, range: 130,
recoilPitch: 0.05, recoilYaw: 0.02, spreadPerShot: 0.2,
baseSpread: 0.06, maxSpread: 0.3, recoverRate: 0.8, moveSpeedMul: 0.84,
scoped: true, scopedFov: 25,
},
glock: {
id: 'glock', name: 'Glock-18', slot: 'secondary',
damage: 26, armorPen: 0.47, fireInterval: 0.15, automatic: false,
magSize: 20, reserve: 120, reloadTime: 2.2, range: 60,
recoilPitch: 0.012, recoilYaw: 0.008, spreadPerShot: 0.011,
baseSpread: 0.008, maxSpread: 0.11, recoverRate: 0.7, moveSpeedMul: 1.0,
},
usp: {
id: 'usp', name: 'USP-S', slot: 'secondary',
damage: 33, armorPen: 0.505, fireInterval: 0.17, automatic: false,
magSize: 12, reserve: 24, reloadTime: 2.2, range: 60,
recoilPitch: 0.012, recoilYaw: 0.006, spreadPerShot: 0.01,
baseSpread: 0.006, maxSpread: 0.1, recoverRate: 0.7, moveSpeedMul: 1.0,
},
deagle: {
id: 'deagle', name: 'Desert Eagle', slot: 'secondary',
damage: 55, armorPen: 0.93, fireInterval: 0.225, automatic: false,
magSize: 7, reserve: 35, reloadTime: 2.2, range: 75,
recoilPitch: 0.032, recoilYaw: 0.012, spreadPerShot: 0.02,
baseSpread: 0.009, maxSpread: 0.14, recoverRate: 0.6, moveSpeedMul: 0.96,
},
knife: {
id: 'knife', name: 'Knife', slot: 'melee',
damage: 55, armorPen: 0.85, fireInterval: 0.4, automatic: false,
magSize: Infinity, reserve: Infinity, reloadTime: 0, range: 2.4,
recoilPitch: 0, recoilYaw: 0, spreadPerShot: 0,
baseSpread: 0, maxSpread: 0, recoverRate: 1, moveSpeedMul: 1.05,
isKnife: true,
},
};
export interface DamageResult {
health: number;
armor: number;
applied: number;
}
// Apply zone + armor model. Head protected only with a helmet; legs never armored.
export function computeDamage(
weapon: WeaponDef,
zone: HitZone,
health: number,
armor: number,
helmet: boolean,
): DamageResult {
let dmg = weapon.damage * ZONE_MULT[zone];
let armorLeft = armor;
const protectable = zone !== HitZone.Leg && (zone !== HitZone.Head || helmet);
if (armorLeft > 0 && protectable) {
const preArmor = dmg;
dmg *= weapon.armorPen;
armorLeft = Math.max(0, armorLeft - Math.round((preArmor - dmg) * 0.5 + dmg * 0.5));
}
const applied = Math.min(health, dmg);
return { health: Math.max(0, health - dmg), armor: armorLeft, applied };
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
// No StrictMode: the game owns an imperative three.js loop with manual setup /
// teardown, and double-invoked effects would spin up two render loops.
createRoot(document.getElementById('root')!).render(<App />);
@@ -0,0 +1,336 @@
:root {
--t: #e0a24a;
--ct: #5aa9e6;
--panel: rgba(12, 15, 20, 0.62);
--panel-solid: #0d1015;
--ink: #eaf0f2;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
background: #05070a;
color: var(--ink);
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, monospace;
user-select: none;
-webkit-user-select: none;
}
/* ---------- menu ---------- */
.menu {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background:
radial-gradient(1200px 700px at 50% -10%, #1b2740 0%, #0a0e16 55%, #05070a 100%);
}
.menu-card {
width: min(680px, 92vw);
padding: 40px 40px 30px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(10, 14, 20, 0.7);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
text-align: center;
}
.menu-title {
font-size: 44px;
letter-spacing: 10px;
font-weight: 800;
background: linear-gradient(90deg, var(--t), #fff 55%, var(--ct));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.menu-sub { margin-top: 8px; color: #93a0ab; font-size: 13px; letter-spacing: 1px; }
.side-select { display: flex; gap: 14px; margin: 26px 0 18px; }
.side {
flex: 1;
padding: 18px 16px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
color: var(--ink);
cursor: pointer;
text-align: left;
transition: transform 0.1s, border-color 0.15s, background 0.15s;
}
.side:hover { transform: translateY(-2px); }
.side-name { display: block; font-size: 17px; font-weight: 700; }
.side-desc { display: block; margin-top: 6px; font-size: 12px; color: #93a0ab; }
.side-t.active { border-color: var(--t); background: rgba(224, 162, 74, 0.14); }
.side-ct.active { border-color: var(--ct); background: rgba(90, 169, 230, 0.14); }
.side-t .side-name { color: var(--t); }
.side-ct .side-name { color: var(--ct); }
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 20px;
margin: 18px 4px;
font-size: 12px;
color: #aab4bd;
text-align: left;
}
.controls b { color: #fff; font-weight: 700; }
.start-btn {
margin-top: 10px;
padding: 14px 40px;
font-size: 16px;
font-weight: 800;
letter-spacing: 3px;
border: none;
border-radius: 10px;
color: #08111e;
background: linear-gradient(90deg, #7fd0ff, #cfeaff);
cursor: pointer;
font-family: inherit;
}
.start-btn:hover { filter: brightness(1.08); }
.menu-foot { margin-top: 14px; font-size: 11px; color: #6a757f; }
/* ---------- game ---------- */
.game-root { position: fixed; inset: 0; cursor: none; }
.game-root canvas { position: absolute; inset: 0; display: block; z-index: 1; }
.hud {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
/* top bar */
.topbar {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 16px;
padding: 6px 14px;
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.team-block { display: flex; align-items: center; gap: 8px; font-weight: 700; }
.team-right { flex-direction: row; }
.team-tag { font-size: 16px; letter-spacing: 1px; }
.team-alive { font-size: 10px; letter-spacing: 1px; }
.dead-dots { opacity: 0.35; }
.score-center { display: flex; align-items: center; gap: 14px; }
.score { font-size: 26px; font-weight: 800; min-width: 26px; text-align: center; }
.score-time { font-size: 15px; color: #cfd8de; min-width: 46px; text-align: center; }
.bomb-time { color: #ff4b3e; animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0.35; } }
/* killfeed */
.killfeed {
position: absolute;
top: 12px;
right: 14px;
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
}
.kill-row {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 9px;
background: var(--panel);
border-radius: 5px;
font-size: 12px;
font-weight: 700;
}
.kill-weapon { color: #cbd3da; font-weight: 500; }
/* banner */
.banner {
position: absolute;
top: 20%;
left: 50%;
transform: translateX(-50%);
font-size: 30px;
font-weight: 800;
letter-spacing: 3px;
color: #fff;
padding: 8px 26px;
background: rgba(8, 11, 16, 0.5);
border-radius: 10px;
white-space: nowrap;
}
/* progress bar */
.progress-wrap {
position: absolute;
top: 62%;
left: 50%;
transform: translateX(-50%);
width: 280px;
text-align: center;
}
.progress-label { font-size: 13px; letter-spacing: 3px; margin-bottom: 6px; }
.progress-bar {
height: 10px;
background: rgba(255, 255, 255, 0.14);
border-radius: 6px;
overflow: hidden;
}
.progress-fill { height: 100%; transition: width 0.05s linear; }
/* crosshair */
.crosshair { position: absolute; left: 50%; top: 50%; width: 0; height: 0; }
/* scope */
.scope { position: absolute; inset: 0; display: grid; place-items: center; }
.scope-circle {
width: min(86vh, 86vw);
height: min(86vh, 86vw);
border-radius: 50%;
box-shadow: 0 0 0 100vmax #000, inset 0 0 0 2px rgba(0, 0, 0, 0.9);
}
.scope-line { position: absolute; background: rgba(0, 0, 0, 0.85); }
.scope-h { width: 100%; height: 1px; }
.scope-v { height: 100%; width: 1px; }
/* dead */
.dead {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.dead-title { font-size: 34px; font-weight: 800; letter-spacing: 6px; color: #ff5b5b; }
.dead-sub { margin-top: 8px; font-size: 13px; color: #b9c2c9; }
/* spectate bar (after death) */
.spectate {
position: absolute;
bottom: 15%;
left: 50%;
transform: translateX(-50%);
text-align: center;
padding: 10px 22px;
background: rgba(8, 11, 16, 0.62);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
white-space: nowrap;
}
.spectate-title { font-size: 16px; letter-spacing: 3px; color: #e7edf1; }
.spectate-name { color: #39d353; font-weight: 800; }
.spectate-keys { margin-top: 6px; font-size: 12px; color: #aab4bd; }
.spectate-keys b { color: #ffcf5b; }
/* hint */
.hint {
position: absolute;
bottom: 27%;
left: 50%;
transform: translateX(-50%);
padding: 6px 16px;
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
font-size: 14px;
letter-spacing: 1px;
}
.bomb-tag {
position: absolute;
bottom: 33%;
left: 50%;
transform: translateX(-50%);
color: var(--t);
font-size: 12px;
letter-spacing: 2px;
}
/* vitals */
.vitals {
position: absolute;
left: 22px;
bottom: 20px;
display: flex;
gap: 22px;
padding: 10px 16px;
background: var(--panel);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.vital { display: flex; align-items: center; gap: 8px; }
.vital-icon { font-size: 15px; font-weight: 800; }
.vital-icon.hp { color: #4fd06a; }
.vital-icon.ar { color: var(--ct); }
.vital-num { font-size: 30px; font-weight: 800; min-width: 44px; }
/* ammo */
.ammo-block {
position: absolute;
right: 22px;
bottom: 20px;
text-align: right;
padding: 10px 16px;
background: var(--panel);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.ammo-weapon { font-size: 13px; letter-spacing: 2px; color: #cbd3da; margin-bottom: 2px; }
.ammo-nums { display: flex; align-items: baseline; justify-content: flex-end; gap: 8px; }
.ammo-mag { font-size: 34px; font-weight: 800; }
.ammo-res { font-size: 16px; color: #9aa4ac; }
.weapon-slots { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; align-items: flex-end; }
.slot { display: flex; gap: 8px; font-size: 12px; opacity: 0.55; }
.slot-cur { opacity: 1; color: #fff; }
.slot-key { color: #ffcf5b; font-weight: 700; }
.slot-name { min-width: 96px; text-align: right; }
/* minimap */
.minimap {
position: absolute;
left: 18px;
top: 14px;
z-index: 6;
width: 200px;
height: 200px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(6, 9, 13, 0.72);
overflow: hidden;
pointer-events: none;
}
.minimap canvas { width: 100%; height: 100%; display: block; }
.minimap-label {
position: absolute;
bottom: 4px;
right: 8px;
font-size: 10px;
letter-spacing: 3px;
color: rgba(255, 255, 255, 0.4);
}
/* click / pause overlay */
.click-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background: rgba(4, 6, 10, 0.55);
backdrop-filter: blur(2px);
pointer-events: auto;
cursor: pointer;
}
.click-inner { text-align: center; }
.click-title { font-size: 30px; font-weight: 800; letter-spacing: 6px; }
.click-hint { margin-top: 10px; font-size: 15px; color: #cfd8de; }
.click-sub { margin-top: 6px; font-size: 12px; color: #7c8790; }