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:
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 · <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 & 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -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;
|
||||
}
|
||||
+132
@@ -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; }
|
||||
Reference in new issue
Block a user