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

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

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

No files matched your search

@@ -0,0 +1,386 @@
import * as THREE from 'three'
import type { GameMap } from './map'
import type { Nav } from './nav'
import type { PhysicsWorld } from './physics'
import type { Soldier } from './soldier'
import { EYE_HEIGHT } from './types'
export type Duty = 'none' | 'plant' | 'defuse' | 'pickup'
export interface Mission {
anchors: THREE.Vector3[]
final: THREE.Vector3
look: THREE.Vector3 | null
duty: Duty
}
export interface AIWorld {
map: GameMap
nav: Nav
physics: PhysicsWorld
soldiers: Soldier[]
time: number
bombPlantedPos: THREE.Vector3 | null
bombDroppedPos: THREE.Vector3 | null
}
const TWO_PI = Math.PI * 2
function angleLerp(a: number, target: number, maxStep: number): number {
let d = (target - a) % TWO_PI
if (d > Math.PI) d -= TWO_PI
if (d < -Math.PI) d += TWO_PI
return a + THREE.MathUtils.clamp(d, -maxStep, maxStep)
}
function angleDiff(a: number, b: number): number {
let d = (b - a) % TWO_PI
if (d > Math.PI) d -= TWO_PI
if (d < -Math.PI) d += TWO_PI
return d
}
/**
* Per-soldier AI: waypoint navigation over the grid, FOV+LOS target acquisition
* with reaction delay, burst-fire combat, plant/defuse/pickup duties.
*/
export class AIBrain {
s: Soldier
private skill: { err: number; react: number; aggro: number; headRate: number }
mission: Mission | null = null
private missionIdx = 0
private arrived = false
private path: THREE.Vector3[] | null = null
private pathIdx = 0
private pathGoal = new THREE.Vector3()
private repathAt = 0
target: Soldier | null = null
private targetSince = 0
private reactUntil = 0
private lastSeenPos = new THREE.Vector3()
private lastSeenAt = -99
private alertUntil = 0
private alertPos: THREE.Vector3 | null = null
private nextThink = 0
private burstUntil = 0
private burstPauseUntil = 0
private strafeDir = 0
private strafeUntil = 0
private scopeStart = 0
private stuckPos = new THREE.Vector3()
private stuckAt = 0
private wanderAt = 0
private lookYawTarget: number | null = null
constructor(s: Soldier) {
this.s = s
this.skill = {
err: 1.1 + Math.random() * 0.9,
react: 0.35 + Math.random() * 0.4,
aggro: Math.random(),
headRate: 0.04 + Math.random() * 0.1,
}
}
reset() {
this.mission = null
this.path = null
this.target = null
this.arrived = false
this.lastSeenAt = -99
this.alertPos = null
this.lookYawTarget = null
}
setMission(anchors: THREE.Vector3[], final: THREE.Vector3, look: THREE.Vector3 | null, duty: Duty) {
this.mission = { anchors: [...anchors], final, look, duty }
this.missionIdx = 0
this.arrived = false
this.path = null
this.lookYawTarget = null
}
heard(pos: THREE.Vector3, time: number) {
if (this.target) return
this.alertUntil = time + 3
this.alertPos = pos.clone()
}
update(dt: number, w: AIWorld) {
const s = this.s
const c = s.control
c.moveX = 0; c.moveZ = 0
c.fire = false; c.fireTap = false; c.altTap = false
c.jump = false; c.use = false; c.reload = false; c.slot = null
if (!s.alive) return
if (w.time >= this.nextThink) {
this.nextThink = w.time + 0.12 + Math.random() * 0.06
this.think(w)
}
if (this.target && w.time >= this.reactUntil) {
this.combat(dt, w)
} else if (this.target) {
this.facePoint(this.target.pos, dt) // reacting: turn but hold fire
} else if (this.lastSeenAt > w.time - 2.5 && this.skill.aggro > 0.35) {
this.chase(dt, w)
} else {
this.missionMove(dt, w)
}
this.dutyBehavior(w)
}
// -------------------------------------------------------------------------
private think(w: AIWorld) {
const s = this.s
// perception
let best: Soldier | null = null
let bestDist = Infinity
const eye = s.eyePos
for (const e of w.soldiers) {
if (!e.alive || e.team === s.team) continue
const dist = e.pos.distanceTo(s.pos)
if (dist > 70 || dist >= bestDist) continue
const dirYaw = Math.atan2(-(e.pos.x - s.pos.x), -(e.pos.z - s.pos.z))
const inFov = Math.abs(angleDiff(s.yaw, dirYaw)) < (w.time < this.alertUntil ? Math.PI : 0.95)
if (!inFov && dist > 3) continue
const targetEye = new THREE.Vector3(e.pos.x, e.pos.y + EYE_HEIGHT * 0.8, e.pos.z)
if (!w.physics.hasLOS(eye, targetEye)) continue
best = e
bestDist = dist
}
if (best) {
if (this.target !== best) {
if (!this.target) this.reactUntil = w.time + this.skill.react
this.target = best
this.targetSince = w.time
}
this.lastSeenPos.copy(best.pos)
this.lastSeenAt = w.time
best.lastSeenBy[this.s.team] = w.time
} else if (this.target) {
if (w.time - this.lastSeenAt > 0.5) this.target = null
}
// reload when safe or empty
const wpn = s.weapon
if (!wpn.isKnife) {
if (wpn.mag === 0 && (wpn.reserve > 0)) s.control.reload = true
else if (!this.target && wpn.mag < wpn.def.magSize * 0.35 && wpn.reserve > 0) s.control.reload = true
// completely dry: go knife
if (wpn.mag === 0 && wpn.reserve === 0) {
if (s.slot === 'primary' && s.weapons.secondary && (s.weapons.secondary.mag > 0 || s.weapons.secondary.reserve > 0)) s.control.slot = 'secondary'
else s.control.slot = 'knife'
}
}
// stuck detection while pathing
if (this.path && !this.arrived) {
if (w.time - this.stuckAt > 1.3) {
if (s.pos.distanceTo(this.stuckPos) < 0.35) {
this.path = null // force repath
this.repathAt = 0
}
this.stuckPos.copy(s.pos)
this.stuckAt = w.time
}
} else {
this.stuckPos.copy(s.pos)
this.stuckAt = w.time
}
}
private combat(dt: number, w: AIWorld) {
const s = this.s
const t = this.target!
const c = s.control
const dist = t.pos.distanceTo(s.pos)
const wpn = s.weapon
// knife rush when using knife
if (wpn.isKnife) {
this.moveToward(t.pos, dt, w, true)
this.facePoint(t.pos, dt)
if (dist < 2.0) c.fire = true
return
}
// aim: chest, occasionally head
const settle = w.time - this.targetSince
const aimY = Math.random() < this.skill.headRate && settle > 0.8 ? 1.62 : 1.15
const aimPoint = new THREE.Vector3(t.pos.x, t.pos.y + aimY, t.pos.z)
const errScale = this.skill.err * (0.45 + 0.55 * Math.exp(-settle * 1.1)) * (0.7 + dist / 55)
const wobble = Math.sin(w.time * 7 + s.id * 3.1) * 0.02 * errScale
aimPoint.x += wobble * dist * 0.5
aimPoint.y += Math.cos(w.time * 6 + s.id) * 0.016 * errScale * dist * 0.5
const desiredYaw = Math.atan2(-(aimPoint.x - s.pos.x), -(aimPoint.z - s.pos.z))
const flatDist = Math.hypot(aimPoint.x - s.pos.x, aimPoint.z - s.pos.z)
const desiredPitch = Math.atan2(aimPoint.y - s.eyePos.y, flatDist)
const turnRate = 6.5 * dt
s.yaw = angleLerp(s.yaw, desiredYaw, turnRate)
s.pitch = THREE.MathUtils.clamp(angleLerp(s.pitch, desiredPitch, turnRate), -1.5, 1.5)
const aligned = Math.abs(angleDiff(s.yaw, desiredYaw)) < 0.055 && Math.abs(s.pitch - desiredPitch) < 0.05
const isAwp = wpn.def.id === 'awp'
if (isAwp) {
if (!s.scoped && aligned) {
c.altTap = true // scope in
this.scopeStart = w.time
}
if (s.scoped && aligned && w.time - this.scopeStart > 0.75) c.fireTap = true
return // AWP AI stands still
}
// burst fire
if (aligned && wpn.mag > 0 && !wpn.reloading) {
if (w.time > this.burstPauseUntil) {
if (this.burstUntil < w.time) {
this.burstUntil = w.time + (wpn.def.auto ? 0.12 + Math.random() * 0.25 : 0.05)
this.burstPauseUntil = this.burstUntil + 0.25 + Math.random() * 0.5
}
}
if (w.time < this.burstUntil) {
c.fire = true
c.fireTap = true
}
}
// strafe jiggle for rifles at medium range
if (dist > 6 && dist < 40 && w.time > this.strafeUntil) {
this.strafeDir = Math.random() < 0.4 ? 0 : (Math.random() < 0.5 ? -1 : 1)
this.strafeUntil = w.time + 0.5 + Math.random() * 0.6
}
c.moveX = this.strafeDir * 0.6
if (dist > 45) c.moveZ = 0.7 // push closer at long range
}
private chase(dt: number, w: AIWorld) {
this.moveToward(this.lastSeenPos, dt, w, false)
this.facePoint(this.lastSeenPos, dt)
if (this.s.pos.distanceTo(this.lastSeenPos) < 2.5) this.lastSeenAt = -99
}
private missionMove(dt: number, w: AIWorld) {
const s = this.s
// face heard noises while holding
if (this.arrived) {
if (this.alertPos && w.time < this.alertUntil) {
this.facePoint(this.alertPos, dt)
} else if (this.mission?.look) {
this.facePoint(this.mission.look, dt)
} else if (this.lookYawTarget !== null) {
s.yaw = angleLerp(s.yaw, this.lookYawTarget, 3 * dt)
}
// occasional gaze wander + micro reposition
if (w.time > this.wanderAt) {
this.wanderAt = w.time + 3 + Math.random() * 4
this.lookYawTarget = s.yaw + (Math.random() - 0.5) * 1.6
if (this.mission?.look && Math.random() < 0.6) this.lookYawTarget = null
}
s.pitch = angleLerp(s.pitch, 0, 2 * dt)
return
}
if (!this.mission) return
const waypoint = this.missionIdx < this.mission.anchors.length
? this.mission.anchors[this.missionIdx]
: this.mission.final
if (this.moveToward(waypoint, dt, w, false)) {
if (this.missionIdx < this.mission.anchors.length) {
this.missionIdx++
this.path = null
} else {
this.arrived = true
this.path = null
}
}
}
/** Steers along an A* path toward `goal`. Returns true when within reach. */
private moveToward(goal: THREE.Vector3, dt: number, w: AIWorld, sprint: boolean): boolean {
const s = this.s
const dGoal = Math.hypot(goal.x - s.pos.x, goal.z - s.pos.z)
if (dGoal < 1.0) return true
if (!this.path || this.pathGoal.distanceTo(goal) > 2 || w.time > this.repathAt) {
this.path = w.nav.findPath(s.pos, goal)
this.pathIdx = 0
this.pathGoal.copy(goal)
this.repathAt = w.time + 3 + Math.random()
if (!this.path || this.path.length === 0) return true
}
let wp = this.path[this.pathIdx]
while (wp && Math.hypot(wp.x - s.pos.x, wp.z - s.pos.z) < 0.7) {
this.pathIdx++
if (this.pathIdx >= this.path.length) return true
wp = this.path[this.pathIdx]
}
if (!wp) return true
const dir = new THREE.Vector3(wp.x - s.pos.x, 0, wp.z - s.pos.z).normalize()
// convert world dir to local move axes
const fwd = new THREE.Vector3(-Math.sin(s.yaw), 0, -Math.cos(s.yaw))
const right = s.rightDir()
s.control.moveZ = dir.dot(fwd)
s.control.moveX = dir.dot(right)
if (!this.target) {
const wantYaw = Math.atan2(-dir.x, -dir.z)
s.yaw = angleLerp(s.yaw, wantYaw, (sprint ? 10 : 6) * dt)
s.pitch = angleLerp(s.pitch, 0, 3 * dt)
}
return false
}
private facePoint(p: THREE.Vector3, dt: number) {
const s = this.s
const wantYaw = Math.atan2(-(p.x - s.pos.x), -(p.z - s.pos.z))
s.yaw = angleLerp(s.yaw, wantYaw, 8 * dt)
}
private dutyBehavior(w: AIWorld) {
const s = this.s
if (!this.mission) return
const c = s.control
switch (this.mission.duty) {
case 'plant': {
if (!s.hasBomb) break
if (this.target) break // fight first
if (w.map.plantSiteAt(s.pos.x, s.pos.z) && this.arrived && s.onGround) {
c.use = true
c.moveX = 0; c.moveZ = 0
}
break
}
case 'defuse': {
if (!w.bombPlantedPos) break
const d = Math.hypot(w.bombPlantedPos.x - s.pos.x, w.bombPlantedPos.z - s.pos.z)
if (d < 1.3 && (!this.target || w.time - this.targetSince < 0.1)) {
c.use = true
c.moveX = 0; c.moveZ = 0
}
break
}
case 'pickup':
break // walking over the bomb picks it up (engine)
case 'none':
break
}
}
}
@@ -0,0 +1,245 @@
import * as THREE from 'three'
type ShotSound = 'ak' | 'm4' | 'awp' | 'pistol' | 'glock' | 'deagle' | 'knife'
/**
* All game audio is synthesized with the Web Audio API — no audio files.
* World sounds are attenuated by distance and stereo-panned relative to the camera.
*/
export class AudioMan {
private ctx: AudioContext | null = null
private master: GainNode | null = null
private noise: AudioBuffer | null = null
private listenerPos = new THREE.Vector3()
private listenerRight = new THREE.Vector3(1, 0, 0)
resume() {
if (!this.ctx) {
this.ctx = new AudioContext()
this.master = this.ctx.createGain()
this.master.gain.value = 0.5
this.master.connect(this.ctx.destination)
const len = this.ctx.sampleRate
this.noise = this.ctx.createBuffer(1, len, this.ctx.sampleRate)
const data = this.noise.getChannelData(0)
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
}
if (this.ctx.state === 'suspended') void this.ctx.resume()
}
setListener(pos: THREE.Vector3, right: THREE.Vector3) {
this.listenerPos.copy(pos)
this.listenerRight.copy(right)
}
private out(pos: THREE.Vector3 | null, gain: number): GainNode | null {
if (!this.ctx || !this.master) return null
const g = this.ctx.createGain()
if (pos) {
const d = pos.distanceTo(this.listenerPos)
const att = 1 / (1 + d * 0.05)
g.gain.value = gain * att
const dir = new THREE.Vector3().subVectors(pos, this.listenerPos)
const dl = dir.length()
const pan = this.ctx.createStereoPanner()
pan.pan.value = dl > 0.5 ? THREE.MathUtils.clamp(dir.dot(this.listenerRight) / dl, -0.85, 0.85) : 0
g.connect(pan)
pan.connect(this.master)
} else {
g.gain.value = gain
g.connect(this.master)
}
return g
}
private playNoise(opts: {
pos: THREE.Vector3 | null, gain: number, dur: number,
filter?: BiquadFilterType, freq?: number, q?: number, freqEnd?: number,
rate?: number, delay?: number,
}) {
if (!this.ctx || !this.noise) return
const dst = this.out(opts.pos, 1)
if (!dst) return
const t0 = this.ctx.currentTime + (opts.delay ?? 0)
const src = this.ctx.createBufferSource()
src.buffer = this.noise
src.loop = true
src.playbackRate.value = opts.rate ?? 1
const env = this.ctx.createGain()
env.gain.setValueAtTime(opts.gain, t0)
env.gain.exponentialRampToValueAtTime(0.001, t0 + opts.dur)
let node: AudioNode = src
if (opts.filter) {
const f = this.ctx.createBiquadFilter()
f.type = opts.filter
f.frequency.setValueAtTime(opts.freq ?? 1000, t0)
if (opts.freqEnd) f.frequency.exponentialRampToValueAtTime(opts.freqEnd, t0 + opts.dur)
f.Q.value = opts.q ?? 1
node.connect(f)
node = f
}
node.connect(env)
env.connect(dst)
src.start(t0, Math.random() * 0.5)
src.stop(t0 + opts.dur + 0.05)
}
private playTone(opts: {
pos: THREE.Vector3 | null, gain: number, dur: number,
freq: number, freqEnd?: number, type?: OscillatorType, delay?: number,
}) {
if (!this.ctx) return
const dst = this.out(opts.pos, 1)
if (!dst) return
const t0 = this.ctx.currentTime + (opts.delay ?? 0)
const osc = this.ctx.createOscillator()
osc.type = opts.type ?? 'sine'
osc.frequency.setValueAtTime(opts.freq, t0)
if (opts.freqEnd) osc.frequency.exponentialRampToValueAtTime(Math.max(1, opts.freqEnd), t0 + opts.dur)
const env = this.ctx.createGain()
env.gain.setValueAtTime(opts.gain, t0)
env.gain.exponentialRampToValueAtTime(0.001, t0 + opts.dur)
osc.connect(env)
env.connect(dst)
osc.start(t0)
osc.stop(t0 + opts.dur + 0.05)
}
shot(kind: ShotSound, pos: THREE.Vector3 | null) {
const r = 0.94 + Math.random() * 0.12
switch (kind) {
case 'ak':
this.playTone({ pos, gain: 0.55, dur: 0.1, freq: 165 * r, freqEnd: 85, type: 'square' })
this.playNoise({ pos, gain: 0.5, dur: 0.11, filter: 'bandpass', freq: 1300, q: 0.7, rate: r })
this.playNoise({ pos, gain: 0.3, dur: 0.025, filter: 'highpass', freq: 4800 })
break
case 'm4':
this.playTone({ pos, gain: 0.4, dur: 0.06, freq: 210 * r, freqEnd: 120, type: 'square' })
this.playNoise({ pos, gain: 0.42, dur: 0.07, filter: 'bandpass', freq: 2100, q: 0.8, rate: r })
this.playNoise({ pos, gain: 0.24, dur: 0.02, filter: 'highpass', freq: 6000 })
break
case 'awp':
this.playTone({ pos, gain: 0.9, dur: 0.35, freq: 92 * r, freqEnd: 42, type: 'sine' })
this.playNoise({ pos, gain: 0.85, dur: 0.32, filter: 'lowpass', freq: 900, freqEnd: 160, rate: r })
this.playNoise({ pos, gain: 0.45, dur: 0.05, filter: 'highpass', freq: 3000 })
break
case 'deagle':
this.playTone({ pos, gain: 0.7, dur: 0.13, freq: 135 * r, freqEnd: 65, type: 'square' })
this.playNoise({ pos, gain: 0.55, dur: 0.13, filter: 'bandpass', freq: 950, q: 0.7, rate: r })
break
case 'pistol':
this.playTone({ pos, gain: 0.32, dur: 0.045, freq: 290 * r, freqEnd: 150, type: 'square' })
this.playNoise({ pos, gain: 0.3, dur: 0.05, filter: 'bandpass', freq: 2400, q: 1, rate: r })
break
case 'glock':
this.playTone({ pos, gain: 0.3, dur: 0.05, freq: 330 * r, freqEnd: 170, type: 'square' })
this.playNoise({ pos, gain: 0.28, dur: 0.055, filter: 'bandpass', freq: 2800, q: 1, rate: r })
break
case 'knife':
this.playNoise({ pos, gain: 0.18, dur: 0.12, filter: 'bandpass', freq: 1800, freqEnd: 600, q: 2 })
break
}
}
knifeHit(pos: THREE.Vector3 | null) {
this.playNoise({ pos, gain: 0.35, dur: 0.08, filter: 'lowpass', freq: 700 })
this.playTone({ pos, gain: 0.2, dur: 0.07, freq: 220, freqEnd: 90 })
}
reload(pos: THREE.Vector3 | null, duration: number) {
const click = (delay: number, freq: number) => {
this.playTone({ pos, gain: 0.18, dur: 0.03, freq, type: 'square', delay })
this.playNoise({ pos, gain: 0.12, dur: 0.03, filter: 'highpass', freq: 2500, delay })
}
click(0.05, 700)
click(duration * 0.45, 500)
click(duration * 0.85, 900)
}
footstep(pos: THREE.Vector3 | null, vol: number) {
this.playNoise({
pos, gain: 0.16 * vol, dur: 0.055,
filter: 'lowpass', freq: 420 + Math.random() * 160, rate: 0.8 + Math.random() * 0.3,
})
}
scope(on: boolean) {
this.playTone({ pos: null, gain: 0.12, dur: 0.05, freq: on ? 1400 : 900, freqEnd: on ? 1900 : 600, type: 'sine' })
}
drawWeapon() {
this.playNoise({ pos: null, gain: 0.1, dur: 0.04, filter: 'highpass', freq: 2000 })
this.playTone({ pos: null, gain: 0.08, dur: 0.03, freq: 600, type: 'square', delay: 0.03 })
}
hit(headshot: boolean) {
if (headshot) {
this.playTone({ pos: null, gain: 0.25, dur: 0.1, freq: 1700, freqEnd: 1400, type: 'triangle' })
this.playTone({ pos: null, gain: 0.15, dur: 0.12, freq: 2550, freqEnd: 2100, type: 'sine', delay: 0.01 })
} else {
this.playTone({ pos: null, gain: 0.18, dur: 0.05, freq: 1150, freqEnd: 900, type: 'triangle' })
}
}
kill() {
this.playTone({ pos: null, gain: 0.2, dur: 0.07, freq: 880, type: 'sine' })
this.playTone({ pos: null, gain: 0.2, dur: 0.1, freq: 1320, type: 'sine', delay: 0.07 })
}
death(pos: THREE.Vector3 | null) {
this.playNoise({ pos, gain: 0.3, dur: 0.2, filter: 'lowpass', freq: 500, freqEnd: 120 })
}
damage() {
this.playNoise({ pos: null, gain: 0.2, dur: 0.09, filter: 'lowpass', freq: 800 })
}
bombBeep(pos: THREE.Vector3 | null, urgent: boolean) {
this.playTone({ pos, gain: urgent ? 0.3 : 0.22, dur: 0.07, freq: urgent ? 2900 : 2500, type: 'square' })
}
bombPlanted() {
for (let i = 0; i < 3; i++) {
this.playTone({ pos: null, gain: 0.16, dur: 0.06, freq: 2100, type: 'square', delay: i * 0.11 })
}
}
plantTick(pos: THREE.Vector3 | null) {
this.playTone({ pos, gain: 0.1, dur: 0.03, freq: 1600, type: 'square' })
}
defuseTick(pos: THREE.Vector3 | null) {
this.playNoise({ pos, gain: 0.14, dur: 0.04, filter: 'highpass', freq: 3200 })
}
bombDefused() {
this.playTone({ pos: null, gain: 0.22, dur: 0.12, freq: 900, type: 'sine' })
this.playTone({ pos: null, gain: 0.22, dur: 0.2, freq: 1350, type: 'sine', delay: 0.12 })
}
explosion(pos: THREE.Vector3 | null) {
this.playNoise({ pos, gain: 1.4, dur: 1.3, filter: 'lowpass', freq: 2800, freqEnd: 60 })
this.playTone({ pos, gain: 1.0, dur: 1.1, freq: 55, freqEnd: 24, type: 'sine' })
this.playNoise({ pos, gain: 0.5, dur: 0.4, filter: 'bandpass', freq: 500, q: 0.5, delay: 0.06 })
}
doorSlide(pos: THREE.Vector3 | null) {
this.playNoise({ pos, gain: 0.16, dur: 0.35, filter: 'bandpass', freq: 300, freqEnd: 500, q: 2 })
}
roundStart() {
this.playTone({ pos: null, gain: 0.18, dur: 0.1, freq: 660, type: 'sine' })
this.playTone({ pos: null, gain: 0.18, dur: 0.16, freq: 990, type: 'sine', delay: 0.1 })
}
roundEnd(win: boolean) {
if (win) {
this.playTone({ pos: null, gain: 0.2, dur: 0.12, freq: 784, type: 'triangle' })
this.playTone({ pos: null, gain: 0.2, dur: 0.12, freq: 988, type: 'triangle', delay: 0.12 })
this.playTone({ pos: null, gain: 0.2, dur: 0.24, freq: 1319, type: 'triangle', delay: 0.24 })
} else {
this.playTone({ pos: null, gain: 0.2, dur: 0.16, freq: 494, type: 'triangle' })
this.playTone({ pos: null, gain: 0.2, dur: 0.3, freq: 330, type: 'triangle', delay: 0.16 })
}
}
}
@@ -0,0 +1,232 @@
import * as THREE from 'three'
import type { Team, WeaponId } from './types'
type GunKind = 'rifle' | 'awp' | 'pistol' | 'knife'
export function gunKindOf(id: WeaponId): GunKind {
if (id === 'awp') return 'awp'
if (id === 'ak47' || id === 'm4a4') return 'rifle'
if (id === 'knife') return 'knife'
return 'pistol'
}
function mat(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({ color, roughness: 0.9 })
}
function box(w: number, h: number, d: number, m: THREE.MeshStandardMaterial): THREE.Mesh {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m)
mesh.castShadow = true
return mesh
}
/**
* Procedural humanoid: separate head / torso / two arms / two legs,
* built entirely from code. Faces -Z. Feet at origin.
*/
export class CharacterRig {
root = new THREE.Group()
private body = new THREE.Group()
private legL: THREE.Mesh
private legR: THREE.Mesh
private head: THREE.Group
private aim = new THREE.Group()
private guns: Record<GunKind, THREE.Group>
private bombPack: THREE.Mesh
private mats: THREE.MeshStandardMaterial[] = []
private walkPhase = 0
private dead = false
private deathT = 0
private fallAxis = 1
constructor(team: Team) {
const skin = mat(0xd8b094)
const shirt = team === 'CT' ? mat(0x3d5a73) : mat(0xa08752)
const pants = team === 'CT' ? mat(0x2c3e50) : mat(0x55483a)
const gear = team === 'CT' ? mat(0x232f3a) : mat(0x4a3b28)
const headgear = team === 'CT' ? mat(0x46586b) : mat(0x7d3030)
this.mats.push(skin, shirt, pants, gear, headgear)
// legs (pivot at hip)
const mkLeg = (x: number) => {
const leg = box(0.17, 0.9, 0.2, pants)
leg.geometry.translate(0, -0.45, 0)
leg.position.set(x, 0.9, 0)
return leg
}
this.legL = mkLeg(-0.12)
this.legR = mkLeg(0.12)
this.body.add(this.legL, this.legR)
// torso + vest
const torso = box(0.46, 0.52, 0.24, shirt)
torso.position.y = 1.21
const vest = box(0.5, 0.3, 0.28, gear)
vest.position.y = 1.14
this.body.add(torso, vest)
// bomb backpack (visible when carrying C4)
this.bombPack = box(0.3, 0.36, 0.16, mat(0x8a2e2e))
this.bombPack.position.set(0, 1.22, 0.22)
this.bombPack.visible = false
this.body.add(this.bombPack)
// head (pivot at neck)
this.head = new THREE.Group()
this.head.position.y = 1.5
const skull = box(0.26, 0.26, 0.26, skin)
skull.position.y = 0.16
this.head.add(skull)
if (team === 'CT') {
const helmet = box(0.3, 0.13, 0.3, headgear)
helmet.position.y = 0.28
this.head.add(helmet)
const visor = box(0.22, 0.06, 0.02, mat(0x111111))
visor.position.set(0, 0.19, -0.14)
this.head.add(visor)
} else {
const wrap = box(0.28, 0.1, 0.28, headgear)
wrap.position.y = 0.27
this.head.add(wrap)
const tail = box(0.06, 0.16, 0.03, headgear)
tail.position.set(0.08, 0.18, 0.15)
this.head.add(tail)
}
this.body.add(this.head)
// aim group: both arms + weapon, pitches with view
this.aim.position.y = 1.42
const mkArm = (x: number, rotX: number, rotZ: number) => {
const arm = box(0.13, 0.42, 0.13, shirt)
arm.geometry.translate(0, -0.21, 0)
const hand = box(0.09, 0.1, 0.09, skin)
hand.position.y = -0.44
arm.add(hand)
arm.position.set(x, 0, 0)
arm.rotation.set(rotX, 0, rotZ)
return arm
}
this.aim.add(mkArm(-0.28, 1.15, 0.4), mkArm(0.28, 1.05, -0.3))
// world-model guns
this.guns = {
rifle: this.buildRifle(),
awp: this.buildAwp(),
pistol: this.buildPistol(),
knife: this.buildKnife(),
}
for (const g of Object.values(this.guns)) {
g.position.set(0.05, -0.24, -0.35)
g.visible = false
this.aim.add(g)
}
this.guns.rifle.visible = true
this.body.add(this.aim)
this.root.add(this.body)
}
private buildRifle(): THREE.Group {
const g = new THREE.Group()
const metal = mat(0x3a3a3a)
const wood = mat(0x6b4a2f)
const body = box(0.06, 0.1, 0.55, metal); body.position.z = -0.15
const barrel = box(0.035, 0.035, 0.35, metal); barrel.position.z = -0.55
const stock = box(0.05, 0.09, 0.22, wood); stock.position.z = 0.2
const magM = box(0.04, 0.16, 0.09, wood); magM.position.set(0, -0.12, -0.1); magM.rotation.x = 0.35
g.add(body, barrel, stock, magM)
return g
}
private buildAwp(): THREE.Group {
const g = new THREE.Group()
const olive = mat(0x4a5d3a)
const metal = mat(0x2e2e2e)
const body = box(0.06, 0.1, 0.7, olive); body.position.z = -0.2
const barrel = box(0.03, 0.03, 0.5, metal); barrel.position.z = -0.78
const scope = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.28, 8), metal)
scope.rotation.x = Math.PI / 2
scope.position.set(0, 0.08, -0.2)
scope.castShadow = true
const stock = box(0.05, 0.11, 0.24, olive); stock.position.z = 0.25
g.add(body, barrel, scope, stock)
return g
}
private buildPistol(): THREE.Group {
const g = new THREE.Group()
const metal = mat(0x333338)
const slide = box(0.045, 0.06, 0.24, metal); slide.position.z = -0.06
const grip = box(0.04, 0.13, 0.06, metal); grip.position.set(0, -0.08, 0.05); grip.rotation.x = 0.25
g.add(slide, grip)
return g
}
private buildKnife(): THREE.Group {
const g = new THREE.Group()
const blade = box(0.015, 0.045, 0.3, mat(0xcfd6dd)); blade.position.z = -0.15
const handle = box(0.03, 0.05, 0.12, mat(0x2f2f33)); handle.position.z = 0.06
g.add(blade, handle)
return g
}
setWeapon(id: WeaponId) {
const kind = gunKindOf(id)
for (const [k, g] of Object.entries(this.guns)) g.visible = k === kind
}
setBomb(visible: boolean) {
this.bombPack.visible = visible
}
spawnReset() {
this.dead = false
this.deathT = 0
this.walkPhase = 0
this.body.rotation.set(0, 0, 0)
this.body.position.set(0, 0, 0)
for (const m of this.mats) { m.transparent = false; m.opacity = 1 }
this.root.visible = true
}
die() {
if (this.dead) return
this.dead = true
this.deathT = 0
this.fallAxis = Math.random() < 0.5 ? 1 : -1
}
update(dt: number, pos: THREE.Vector3, yaw: number, pitch: number, speed: number, onGround: boolean) {
this.root.position.copy(pos)
this.root.rotation.y = yaw
if (this.dead) {
this.deathT += dt
const k = Math.min(1, this.deathT / 0.4)
this.body.rotation.z = this.fallAxis * (Math.PI / 2) * k * k * (3 - 2 * k)
this.body.position.y = 0.12 * k
if (this.deathT > 6) {
const fade = Math.max(0, 1 - (this.deathT - 6) / 1.5)
for (const m of this.mats) { m.transparent = true; m.opacity = fade }
if (fade <= 0) this.root.visible = false
}
return
}
const speedRatio = Math.min(1, speed / 4.7)
this.walkPhase += speed * dt * 2.2
const swing = onGround ? Math.sin(this.walkPhase) * 0.7 * speedRatio : 0.25
this.legL.rotation.x = swing
this.legR.rotation.x = -swing
this.body.position.y = onGround ? Math.abs(Math.sin(this.walkPhase)) * 0.045 * speedRatio : 0.05
this.aim.rotation.x = pitch
this.head.rotation.x = pitch * 0.55
}
dispose(scene: THREE.Scene) {
scene.remove(this.root)
this.root.traverse(o => {
if (o instanceof THREE.Mesh) o.geometry.dispose()
})
for (const m of this.mats) m.dispose()
}
}
@@ -0,0 +1,200 @@
import * as THREE from 'three'
const MAX_TRACERS = 96
const MAX_SPARKS = 768
const MAX_BLOOD = 384
interface Particle { life: number; max: number; vx: number; vy: number; vz: number; gravity: number }
class ParticlePool {
points: THREE.Points
pos: Float32Array
col: Float32Array
parts: Particle[]
baseCol: Float32Array
private cursor = 0
constructor(count: number, size: number, additive: boolean) {
this.pos = new Float32Array(count * 3).fill(-999)
this.col = new Float32Array(count * 3)
this.baseCol = new Float32Array(count * 3)
this.parts = Array.from({ length: count }, () => ({ life: 0, max: 1, vx: 0, vy: 0, vz: 0, gravity: 0 }))
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(this.pos, 3))
geo.setAttribute('color', new THREE.BufferAttribute(this.col, 3))
const matP = new THREE.PointsMaterial({
size, vertexColors: true, transparent: true, depthWrite: false,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
sizeAttenuation: true,
})
this.points = new THREE.Points(geo, matP)
this.points.frustumCulled = false
}
spawn(p: THREE.Vector3, vel: THREE.Vector3, life: number, color: THREE.Color, gravity: number) {
const i = this.cursor
this.cursor = (this.cursor + 1) % this.parts.length
this.pos[i * 3] = p.x; this.pos[i * 3 + 1] = p.y; this.pos[i * 3 + 2] = p.z
this.baseCol[i * 3] = color.r; this.baseCol[i * 3 + 1] = color.g; this.baseCol[i * 3 + 2] = color.b
const part = this.parts[i]
part.life = life; part.max = life
part.vx = vel.x; part.vy = vel.y; part.vz = vel.z
part.gravity = gravity
}
update(dt: number) {
for (let i = 0; i < this.parts.length; i++) {
const p = this.parts[i]
if (p.life <= 0) continue
p.life -= dt
if (p.life <= 0) {
this.pos[i * 3 + 1] = -999
this.col[i * 3] = this.col[i * 3 + 1] = this.col[i * 3 + 2] = 0
continue
}
p.vy -= p.gravity * dt
this.pos[i * 3] += p.vx * dt
this.pos[i * 3 + 1] += p.vy * dt
this.pos[i * 3 + 2] += p.vz * dt
const f = p.life / p.max
this.col[i * 3] = this.baseCol[i * 3] * f
this.col[i * 3 + 1] = this.baseCol[i * 3 + 1] * f
this.col[i * 3 + 2] = this.baseCol[i * 3 + 2] * f
}
this.points.geometry.attributes.position.needsUpdate = true
this.points.geometry.attributes.color.needsUpdate = true
}
}
export class Effects {
private scene: THREE.Scene
private tracerGeo: THREE.BufferGeometry
private tracerPos: Float32Array
private tracerCol: Float32Array
private tracers: { life: number; max: number }[]
private tracerCursor = 0
private sparks: ParticlePool
private blood: ParticlePool
private muzzleLight: THREE.PointLight
private muzzleT = 0
private explosionLight: THREE.PointLight
private explosionT = 0
private rings: { mesh: THREE.Mesh; life: number }[] = []
shake = 0
constructor(scene: THREE.Scene) {
this.scene = scene
this.tracerPos = new Float32Array(MAX_TRACERS * 6).fill(-999)
this.tracerCol = new Float32Array(MAX_TRACERS * 6)
this.tracers = Array.from({ length: MAX_TRACERS }, () => ({ life: 0, max: 1 }))
this.tracerGeo = new THREE.BufferGeometry()
this.tracerGeo.setAttribute('position', new THREE.BufferAttribute(this.tracerPos, 3))
this.tracerGeo.setAttribute('color', new THREE.BufferAttribute(this.tracerCol, 3))
const lines = new THREE.LineSegments(this.tracerGeo, new THREE.LineBasicMaterial({
vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
}))
lines.frustumCulled = false
scene.add(lines)
this.sparks = new ParticlePool(MAX_SPARKS, 0.09, true)
this.blood = new ParticlePool(MAX_BLOOD, 0.11, false)
scene.add(this.sparks.points, this.blood.points)
this.muzzleLight = new THREE.PointLight(0xffc070, 0, 14)
this.explosionLight = new THREE.PointLight(0xff8030, 0, 60)
scene.add(this.muzzleLight, this.explosionLight)
}
tracer(a: THREE.Vector3, b: THREE.Vector3) {
const i = this.tracerCursor
this.tracerCursor = (this.tracerCursor + 1) % MAX_TRACERS
this.tracerPos.set([a.x, a.y, a.z, b.x, b.y, b.z], i * 6)
this.tracers[i].life = this.tracers[i].max = 0.07
}
muzzle(pos: THREE.Vector3) {
this.muzzleLight.position.copy(pos)
this.muzzleLight.intensity = 6
this.muzzleT = 0.05
}
impact(pos: THREE.Vector3) {
const c = new THREE.Color(0xffcc88)
for (let i = 0; i < 7; i++) {
const vel = new THREE.Vector3((Math.random() - 0.5) * 5, Math.random() * 4, (Math.random() - 0.5) * 5)
this.sparks.spawn(pos, vel, 0.22 + Math.random() * 0.18, c, 12)
}
}
bloodBurst(pos: THREE.Vector3) {
const c = new THREE.Color(0x8a1515)
for (let i = 0; i < 10; i++) {
const vel = new THREE.Vector3((Math.random() - 0.5) * 3.4, Math.random() * 2.6 - 0.4, (Math.random() - 0.5) * 3.4)
this.blood.spawn(pos, vel, 0.3 + Math.random() * 0.25, c, 9)
}
}
explosion(pos: THREE.Vector3) {
const fire = new THREE.Color(0xff9040)
const smoke = new THREE.Color(0x553311)
for (let i = 0; i < 130; i++) {
const dir = new THREE.Vector3(Math.random() - 0.5, Math.random() * 0.8, Math.random() - 0.5).normalize()
const sp = 4 + Math.random() * 16
this.sparks.spawn(pos.clone().add(new THREE.Vector3(0, 0.5, 0)), dir.multiplyScalar(sp), 0.5 + Math.random() * 0.9, fire, 6)
}
for (let i = 0; i < 60; i++) {
const dir = new THREE.Vector3(Math.random() - 0.5, Math.random(), Math.random() - 0.5).normalize()
this.blood.spawn(pos.clone(), dir.multiplyScalar(2 + Math.random() * 6), 1 + Math.random(), smoke, -1.5)
}
this.explosionLight.position.copy(pos).add(new THREE.Vector3(0, 2, 0))
this.explosionLight.intensity = 80
this.explosionT = 0.9
this.shake = Math.max(this.shake, 1)
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.5, 1.2, 32),
new THREE.MeshBasicMaterial({ color: 0xffbb66, transparent: true, opacity: 0.9, side: THREE.DoubleSide, depthWrite: false }),
)
ring.rotation.x = -Math.PI / 2
ring.position.copy(pos).add(new THREE.Vector3(0, 0.3, 0))
this.scene.add(ring)
this.rings.push({ mesh: ring, life: 0.8 })
}
update(dt: number) {
for (let i = 0; i < MAX_TRACERS; i++) {
const t = this.tracers[i]
if (t.life <= 0) continue
t.life -= dt
const f = Math.max(0, t.life / t.max)
const r = 1 * f, g2 = 0.85 * f, b = 0.5 * f
this.tracerCol.set([r, g2, b, r * 0.6, g2 * 0.6, b * 0.6], i * 6)
if (t.life <= 0) this.tracerPos.set([-999, -999, -999, -999, -999, -999], i * 6)
}
this.tracerGeo.attributes.position.needsUpdate = true
this.tracerGeo.attributes.color.needsUpdate = true
this.sparks.update(dt)
this.blood.update(dt)
this.muzzleT -= dt
if (this.muzzleT <= 0) this.muzzleLight.intensity = 0
if (this.explosionT > 0) {
this.explosionT -= dt
this.explosionLight.intensity = Math.max(0, 80 * (this.explosionT / 0.9))
}
for (let i = this.rings.length - 1; i >= 0; i--) {
const r = this.rings[i]
r.life -= dt
const s = 1 + (0.8 - r.life) * 30
r.mesh.scale.set(s, s, 1)
;(r.mesh.material as THREE.MeshBasicMaterial).opacity = Math.max(0, r.life)
if (r.life <= 0) {
this.scene.remove(r.mesh)
r.mesh.geometry.dispose()
this.rings.splice(i, 1)
}
}
this.shake *= Math.exp(-4 * dt)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,429 @@
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import type { Team } from './types'
export const CELL = 2.5
export const GRID = 52
export const WALL_H = 4
const HALF = GRID / 2 // world origin at grid center
export const enum Code { Wall = 0, Floor = 1, TSpawn = 2, CTSpawn = 3, ASite = 4, BSite = 5, Door = 6 }
// ---------------------------------------------------------------------------
// Layout: carved rectangles on a 52x52 grid (col, row; row 0 = north = -Z).
// Regions: T spawn, CT spawn, Long A, A site, mid + mid doors, catwalk (short),
// B tunnels, B site — wired up per the real Dust2 topology.
// ---------------------------------------------------------------------------
type Rect = [x1: number, y1: number, x2: number, y2: number, code: Code]
const RECTS: Rect[] = [
[2, 2, 13, 12, Code.BSite], // B 点
[6, 13, 9, 30, Code.Floor], // B洞 竖段
[6, 28, 20, 31, Code.Floor], // B洞 横段
[17, 31, 20, 41, Code.Floor], // B洞 → T 出生点
[18, 42, 34, 49, Code.TSpawn], // T 出生点
[24, 38, 29, 42, Code.Floor], // T → 中路
[24, 10, 29, 38, Code.Floor], // 中路
[22, 3, 34, 10, Code.CTSpawn], // CT 出生点
[13, 10, 25, 13, Code.Floor], // B门通道 (CT ↔ B)
[29, 20, 38, 23, Code.Floor], // 猫道横段
[35, 10, 38, 23, Code.Floor], // A小 竖段
[36, 2, 47, 9, Code.ASite], // A 点
[44, 9, 47, 34, Code.Floor], // A大 竖段
[30, 34, 47, 38, Code.Floor], // A大 外围横段
[30, 38, 34, 42, Code.Floor], // T → A大
[34, 3, 36, 7, Code.Floor], // CT → A 匝道
]
// tunnels get ceilings so B洞 reads as an actual tunnel
const TUNNEL_RECTS: Rect[] = [
[6, 13, 9, 30, Code.Floor],
[6, 28, 20, 31, Code.Floor],
]
const CRATES: [c: number, r: number, stack: number][] = [
// A site
[40, 4, 2], [41, 4, 1], [40, 5, 1], [44, 7, 1], [37, 3, 1],
// B site
[5, 4, 1], [6, 4, 2], [10, 9, 1], [11, 9, 1], [4, 10, 1],
// mid boxes
[25, 34, 1], [26, 34, 2],
// catwalk xbox
[31, 21, 1],
// long corner
[46, 35, 1], [46, 36, 2],
// tunnels + spawns
[8, 21, 1], [20, 43, 1], [32, 4, 1],
]
export interface AABB { min: THREE.Vector3; max: THREE.Vector3 }
export class DoorObj {
center: THREE.Vector3
totalW: number
panelW: number
open01 = 0
target = 0
panels: [THREE.Mesh, THREE.Mesh]
group = new THREE.Group()
constructor(center: THREE.Vector3, totalW: number) {
this.center = center
this.totalW = totalW
this.panelW = totalW / 2
const mat = new THREE.MeshStandardMaterial({ color: 0x5c6e60, roughness: 0.7, metalness: 0.3 })
const geo = new THREE.BoxGeometry(this.panelW, 3.2, 0.25)
const left = new THREE.Mesh(geo, mat)
const right = new THREE.Mesh(geo, mat)
left.castShadow = right.castShadow = true
this.panels = [left, right]
this.group.add(left, right)
// door frame header
const header = new THREE.Mesh(
new THREE.BoxGeometry(totalW + 0.6, WALL_H - 3.2, 0.6),
new THREE.MeshStandardMaterial({ color: 0x8f7f5c, roughness: 0.95 }),
)
header.position.set(center.x, 3.2 + (WALL_H - 3.2) / 2, center.z)
this.group.add(header)
this.layout()
}
private layout() {
const slide = this.open01 * (this.panelW + 0.3)
this.panels[0].position.set(this.center.x - this.panelW / 2 - slide, 1.6, this.center.z)
this.panels[1].position.set(this.center.x + this.panelW / 2 + slide, 1.6, this.center.z)
}
/** Returns 'open' | 'close' when the door starts moving (for audio). */
update(dt: number, positions: THREE.Vector3[]): 'open' | 'close' | null {
let near = false
for (const p of positions) {
const dx = p.x - this.center.x, dz = p.z - this.center.z
if (dx * dx + dz * dz < 4.2 * 4.2) { near = true; break }
}
const prevTarget = this.target
this.target = near ? 1 : 0
const before = this.open01
const k = 1 - Math.exp(-5 * dt)
this.open01 += (this.target - this.open01) * k
if (Math.abs(this.open01 - before) > 1e-5) this.layout()
if (prevTarget !== this.target) return this.target === 1 ? 'open' : 'close'
return null
}
colliders(): AABB[] {
if (this.open01 > 0.92) return []
const out: AABB[] = []
for (const p of this.panels) {
out.push({
min: new THREE.Vector3(p.position.x - this.panelW / 2, 0, this.center.z - 0.2),
max: new THREE.Vector3(p.position.x + this.panelW / 2, 3.2, this.center.z + 0.2),
})
}
return out
}
}
export interface HoldSpot { pos: THREE.Vector3; look: THREE.Vector3 }
function makeFloorTexture(): THREE.CanvasTexture {
const c = document.createElement('canvas')
c.width = c.height = 256
const g = c.getContext('2d')!
g.fillStyle = '#c9b183'
g.fillRect(0, 0, 256, 256)
for (let i = 0; i < 2600; i++) {
const v = Math.random()
g.fillStyle = v < 0.5 ? 'rgba(120,98,64,0.14)' : 'rgba(240,226,190,0.12)'
g.fillRect(Math.random() * 256, Math.random() * 256, 1 + Math.random() * 2.4, 1 + Math.random() * 2.4)
}
g.strokeStyle = 'rgba(90,74,50,0.20)'
g.lineWidth = 1
g.strokeRect(0.5, 0.5, 255, 255)
const tex = new THREE.CanvasTexture(c)
tex.wrapS = tex.wrapT = THREE.RepeatWrapping
tex.repeat.set(GRID, GRID)
tex.colorSpace = THREE.SRGBColorSpace
return tex
}
function makeLetterTexture(text: string, color: string): THREE.CanvasTexture {
const c = document.createElement('canvas')
c.width = c.height = 128
const g = c.getContext('2d')!
g.clearRect(0, 0, 128, 128)
g.font = 'bold 96px sans-serif'
g.textAlign = 'center'
g.textBaseline = 'middle'
g.fillStyle = color
g.fillText(text, 64, 68)
const tex = new THREE.CanvasTexture(c)
tex.colorSpace = THREE.SRGBColorSpace
return tex
}
export class GameMap {
grid = new Uint8Array(GRID * GRID) // Code values
crateCells = new Set<number>()
crates: AABB[] = []
doors: DoorObj[] = []
group = new THREE.Group()
spawns: Record<Team, { pos: THREE.Vector3; yaw: number }[]> = { CT: [], T: [] }
anchors: Record<string, THREE.Vector3> = {}
holdSpots: { A: HoldSpot[]; B: HoldSpot[]; mid: HoldSpot[] } = { A: [], B: [], mid: [] }
routes: { A: THREE.Vector3[][]; B: THREE.Vector3[][] } = { A: [], B: [] }
siteCells: { A: [number, number][]; B: [number, number][] } = { A: [], B: [] }
constructor() {
this.buildGrid()
this.buildVisuals()
this.buildMeta()
}
code(c: number, r: number): Code {
if (c < 0 || r < 0 || c >= GRID || r >= GRID) return Code.Wall
return this.grid[r * GRID + c] as Code
}
isWall(c: number, r: number): boolean { return this.code(c, r) === Code.Wall }
isWalkableCell(c: number, r: number): boolean {
return this.code(c, r) !== Code.Wall && !this.crateCells.has(r * GRID + c)
}
cellCenter(c: number, r: number): THREE.Vector3 {
return new THREE.Vector3((c - HALF + 0.5) * CELL, 0, (r - HALF + 0.5) * CELL)
}
worldToCell(x: number, z: number): [number, number] {
return [Math.floor(x / CELL + HALF), Math.floor(z / CELL + HALF)]
}
plantSiteAt(x: number, z: number): 'A' | 'B' | null {
const [c, r] = this.worldToCell(x, z)
const code = this.code(c, r)
if (code === Code.ASite) return 'A'
if (code === Code.BSite) return 'B'
return null
}
private buildGrid() {
for (const [x1, y1, x2, y2, code] of RECTS) {
for (let r = y1; r <= y2; r++) {
for (let c = x1; c <= x2; c++) this.grid[r * GRID + c] = code
}
}
// 中门: frame + door cells at row 16
this.grid[16 * GRID + 24] = Code.Wall
this.grid[16 * GRID + 29] = Code.Wall
for (let c = 25; c <= 28; c++) this.grid[16 * GRID + c] = Code.Door
// A大双门 at row 32
this.grid[32 * GRID + 44] = Code.Wall
this.grid[32 * GRID + 47] = Code.Wall
for (let c = 45; c <= 46; c++) this.grid[32 * GRID + c] = Code.Door
for (const [c, r, stack] of CRATES) {
this.crateCells.add(r * GRID + c)
const ctr = this.cellCenter(c, r)
const s = 1.05
this.crates.push({
min: new THREE.Vector3(ctr.x - s, 0, ctr.z - s),
max: new THREE.Vector3(ctr.x + s, 1.05 * stack, ctr.z + s),
})
}
}
private buildVisuals() {
// floor
const floorMat = new THREE.MeshStandardMaterial({ map: makeFloorTexture(), roughness: 1 })
const floor = new THREE.Mesh(new THREE.PlaneGeometry(GRID * CELL, GRID * CELL), floorMat)
floor.rotation.x = -Math.PI / 2
floor.receiveShadow = true
this.group.add(floor)
// walls: merge exposed wall cells into per-row runs
const exposed = (c: number, r: number): boolean => {
if (!this.isWall(c, r)) return false
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dc === 0 && dr === 0) continue
if (this.code(c + dc, r + dr) !== Code.Wall) return true
}
}
return false
}
const geos: THREE.BufferGeometry[] = []
const base = new THREE.Color('#c8ae7d')
for (let r = 0; r < GRID; r++) {
let c = 0
while (c < GRID) {
if (!exposed(c, r)) { c++; continue }
let end = c
while (end + 1 < GRID && exposed(end + 1, r)) end++
const w = (end - c + 1) * CELL
const g = new THREE.BoxGeometry(w, WALL_H, CELL)
const cx = ((c + end + 1) / 2 - HALF) * CELL
const cz = (r - HALF + 0.5) * CELL
g.translate(cx, WALL_H / 2, cz)
const col = base.clone().multiplyScalar(0.9 + Math.random() * 0.18)
const n = g.attributes.position.count
const colors = new Float32Array(n * 3)
for (let i = 0; i < n; i++) { colors[i * 3] = col.r; colors[i * 3 + 1] = col.g; colors[i * 3 + 2] = col.b }
g.setAttribute('color', new THREE.BufferAttribute(colors, 3))
geos.push(g)
c = end + 1
}
}
const wallGeo = mergeGeometries(geos)!
const walls = new THREE.Mesh(wallGeo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.95 }))
walls.castShadow = true
walls.receiveShadow = true
this.group.add(walls)
// crates
const crateMat = new THREE.MeshStandardMaterial({ color: 0x9a7743, roughness: 0.9 })
const crateEdge = new THREE.MeshStandardMaterial({ color: 0x6e5430, roughness: 0.9 })
for (const [c, r, stack] of CRATES) {
const ctr = this.cellCenter(c, r)
for (let s = 0; s < stack; s++) {
const m = new THREE.Mesh(new THREE.BoxGeometry(2.1, 1.02, 2.1), crateMat)
m.position.set(ctr.x, 0.525 + s * 1.05, ctr.z)
m.castShadow = m.receiveShadow = true
this.group.add(m)
const trim = new THREE.Mesh(new THREE.BoxGeometry(2.16, 0.12, 2.16), crateEdge)
trim.position.set(ctr.x, 1.0 + s * 1.05, ctr.z)
this.group.add(trim)
}
}
// tunnel ceilings
const ceilMat = new THREE.MeshStandardMaterial({ color: 0xa08a5f, roughness: 1 })
for (const [x1, y1, x2, y2] of TUNNEL_RECTS) {
const w = (x2 - x1 + 1) * CELL, d = (y2 - y1 + 1) * CELL
const m = new THREE.Mesh(new THREE.BoxGeometry(w + CELL, 0.4, d + CELL), ceilMat)
m.position.set(((x1 + x2 + 1) / 2 - HALF) * CELL, 3.3, ((y1 + y2 + 1) / 2 - HALF) * CELL)
m.castShadow = true
this.group.add(m)
}
// site letters painted on the floor
const markers: [string, number, number, string][] = [
['A', 41, 5, 'rgba(214,120,50,0.85)'],
['B', 7, 7, 'rgba(214,120,50,0.85)'],
['MID', 26, 24, 'rgba(90,74,50,0.6)'],
]
for (const [text, c, r, color] of markers) {
const tex = makeLetterTexture(text, color)
const size = text.length > 1 ? 7 : 5
const m = new THREE.Mesh(
new THREE.PlaneGeometry(size, size),
new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false }),
)
const p = this.cellCenter(c, r)
m.rotation.x = -Math.PI / 2
m.position.set(p.x, 0.02, p.z)
this.group.add(m)
}
// doors
const midDoor = new DoorObj(this.cellCenter(26, 16).setX(((25 + 29) / 2 - HALF) * CELL), 4 * CELL)
const longDoor = new DoorObj(this.cellCenter(45, 32).setX(((45 + 47) / 2 - HALF) * CELL), 2 * CELL)
this.doors = [midDoor, longDoor]
for (const d of this.doors) this.group.add(d.group)
}
private buildMeta() {
const cc = (c: number, r: number) => this.cellCenter(c, r)
// spawns
const tCells: [number, number][] = [[25, 46], [27, 46], [23, 45], [29, 45], [26, 47]]
const ctCells: [number, number][] = [[27, 5], [29, 5], [25, 4], [31, 6], [28, 7]]
this.spawns.T = tCells.map(([c, r]) => ({ pos: cc(c, r), yaw: 0 }))
this.spawns.CT = ctCells.map(([c, r]) => ({ pos: cc(c, r), yaw: Math.PI }))
const an = this.anchors
an.aSite = cc(41, 5); an.bSite = cc(7, 7)
an.tSpawn = cc(26, 45); an.ctSpawn = cc(28, 6)
an.midLow = cc(26, 32); an.midDoorS = cc(26, 18); an.midTop = cc(26, 11)
an.catwalk = cc(33, 21); an.aShort = cc(36, 12)
an.outsideLong = cc(38, 36); an.longMid = cc(45, 26); an.longTop = cc(45, 11)
an.tunnelS = cc(18, 36); an.tunnelElbow = cc(8, 29); an.tunnelMid = cc(7, 20)
an.bDoors = cc(18, 11)
this.routes.A = [
[an.outsideLong, an.longMid, an.longTop],
[an.midLow, an.catwalk, an.aShort],
]
this.routes.B = [
[an.tunnelS, an.tunnelElbow, an.tunnelMid],
[an.midLow, an.midDoorS, an.midTop, an.bDoors],
]
this.holdSpots.A = [
{ pos: cc(38, 3), look: cc(45, 11) },
{ pos: cc(43, 8), look: cc(36, 13) },
{ pos: cc(45, 11), look: cc(45, 28) },
]
this.holdSpots.B = [
{ pos: cc(4, 4), look: cc(7, 15) },
{ pos: cc(10, 10), look: cc(15, 11) },
{ pos: cc(7, 14), look: cc(7, 22) },
]
this.holdSpots.mid = [
{ pos: cc(26, 12), look: cc(26, 20) },
{ pos: cc(18, 11), look: cc(25, 11) },
]
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const code = this.code(c, r)
if (code === Code.ASite && this.isWalkableCell(c, r)) this.siteCells.A.push([c, r])
if (code === Code.BSite && this.isWalkableCell(c, r)) this.siteCells.B.push([c, r])
}
}
}
randomSiteSpot(site: 'A' | 'B'): THREE.Vector3 {
const cells = this.siteCells[site]
const [c, r] = cells[Math.floor(Math.random() * cells.length)]
return this.cellCenter(c, r)
}
/** Random walkable cell center within `radius` (world units) of a point. */
randomSpotNear(p: THREE.Vector3, radius: number): THREE.Vector3 {
const [pc, pr] = this.worldToCell(p.x, p.z)
const cellRad = Math.ceil(radius / CELL)
for (let i = 0; i < 24; i++) {
const c = pc + Math.round((Math.random() * 2 - 1) * cellRad)
const r = pr + Math.round((Math.random() * 2 - 1) * cellRad)
if (this.isWalkableCell(c, r)) return this.cellCenter(c, r)
}
return p.clone()
}
drawMinimap(ctx: CanvasRenderingContext2D, px: number) {
ctx.clearRect(0, 0, GRID * px, GRID * px)
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const code = this.code(c, r)
if (code === Code.Wall) continue
let fill = 'rgba(219,197,150,0.85)'
if (code === Code.ASite || code === Code.BSite) fill = 'rgba(226,160,96,0.9)'
if (code === Code.TSpawn) fill = 'rgba(216,190,132,0.9)'
if (code === Code.CTSpawn) fill = 'rgba(180,198,216,0.9)'
if (code === Code.Door) fill = 'rgba(140,170,140,0.9)'
ctx.fillStyle = fill
ctx.fillRect(c * px, r * px, px, px)
if (this.crateCells.has(r * GRID + c)) {
ctx.fillStyle = 'rgba(120,95,55,0.9)'
ctx.fillRect(c * px + 1, r * px + 1, px - 2, px - 2)
}
}
}
ctx.font = `bold ${px * 5}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = 'rgba(80,50,20,0.8)'
ctx.fillText('A', 41.5 * px, 6 * px)
ctx.fillText('B', 7.5 * px, 7.5 * px)
}
}
@@ -0,0 +1,155 @@
import * as THREE from 'three'
import { CELL, GRID, GameMap } from './map'
import type { PhysicsWorld } from './physics'
const HALF = GRID / 2
/** Grid A* with octile heuristic + string-pulling smoothing. */
export class Nav {
private map: GameMap
private physics: PhysicsWorld
private walkable: Uint8Array
constructor(map: GameMap, physics: PhysicsWorld) {
this.map = map
this.physics = physics
this.walkable = new Uint8Array(GRID * GRID)
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
this.walkable[r * GRID + c] = map.isWalkableCell(c, r) ? 1 : 0
}
}
}
private ok(c: number, r: number): boolean {
return c >= 0 && r >= 0 && c < GRID && r < GRID && this.walkable[r * GRID + c] === 1
}
private nearestWalkable(c: number, r: number): [number, number] | null {
if (this.ok(c, r)) return [c, r]
for (let rad = 1; rad <= 6; rad++) {
for (let dr = -rad; dr <= rad; dr++) {
for (let dc = -rad; dc <= rad; dc++) {
if (Math.max(Math.abs(dc), Math.abs(dr)) !== rad) continue
if (this.ok(c + dc, r + dr)) return [c + dc, r + dr]
}
}
}
return null
}
findPath(from: THREE.Vector3, to: THREE.Vector3): THREE.Vector3[] | null {
const s = this.nearestWalkable(...this.map.worldToCell(from.x, from.z))
const e = this.nearestWalkable(...this.map.worldToCell(to.x, to.z))
if (!s || !e) return null
const cells = this.aStar(s[0], s[1], e[0], e[1])
if (!cells) return null
const pts: THREE.Vector3[] = cells.map(i => this.map.cellCenter(i % GRID, Math.floor(i / GRID)))
pts.push(new THREE.Vector3(to.x, 0, to.z))
// string pulling
const smoothed: THREE.Vector3[] = []
let i = 0
const start = new THREE.Vector3(from.x, 0, from.z)
let cur = start
while (i < pts.length) {
let j = pts.length - 1
let found = i
for (; j > i; j--) {
if (this.physics.walkClear(cur, pts[j], 0.45)) { found = j; break }
}
cur = pts[found]
smoothed.push(cur)
i = found + 1
}
return smoothed
}
private aStar(sc: number, sr: number, ec: number, er: number): number[] | null {
const n = GRID * GRID
const g = new Float32Array(n).fill(Infinity)
const came = new Int32Array(n).fill(-1)
const closed = new Uint8Array(n)
const start = sr * GRID + sc
const end = er * GRID + ec
g[start] = 0
// binary heap of [f, idx]
const heapF: number[] = []
const heapI: number[] = []
const push = (f: number, idx: number) => {
heapF.push(f); heapI.push(idx)
let i = heapF.length - 1
while (i > 0) {
const p = (i - 1) >> 1
if (heapF[p] <= heapF[i]) break
;[heapF[p], heapF[i]] = [heapF[i], heapF[p]]
;[heapI[p], heapI[i]] = [heapI[i], heapI[p]]
i = p
}
}
const pop = (): number => {
const top = heapI[0]
const lf = heapF.pop()!, li = heapI.pop()!
if (heapF.length > 0) {
heapF[0] = lf; heapI[0] = li
let i = 0
for (;;) {
const l = i * 2 + 1, r = l + 1
let m = i
if (l < heapF.length && heapF[l] < heapF[m]) m = l
if (r < heapF.length && heapF[r] < heapF[m]) m = r
if (m === i) break
;[heapF[m], heapF[i]] = [heapF[i], heapF[m]]
;[heapI[m], heapI[i]] = [heapI[i], heapI[m]]
i = m
}
}
return top
}
const h = (idx: number) => {
const dc = Math.abs((idx % GRID) - ec)
const dr = Math.abs(Math.floor(idx / GRID) - er)
return Math.max(dc, dr) + 0.414 * Math.min(dc, dr)
}
push(h(start), start)
let expansions = 0
while (heapF.length > 0 && expansions < 8000) {
const cur = pop()
if (cur === end) {
const path: number[] = []
let c = end
while (c !== -1) { path.push(c); c = came[c] }
path.reverse()
return path
}
if (closed[cur]) continue
closed[cur] = 1
expansions++
const cc = cur % GRID, cr = Math.floor(cur / GRID)
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dc === 0 && dr === 0) continue
const nc = cc + dc, nr = cr + dr
if (!this.ok(nc, nr)) continue
// no corner cutting
if (dc !== 0 && dr !== 0 && (!this.ok(cc + dc, cr) || !this.ok(cc, cr + dr))) continue
const ni = nr * GRID + nc
if (closed[ni]) continue
const cost = dc !== 0 && dr !== 0 ? 1.414 : 1
const ng = g[cur] + cost
if (ng < g[ni]) {
g[ni] = ng
came[ni] = cur
push(ng + h(ni), ni)
}
}
}
}
return null
}
}
export { CELL }
@@ -0,0 +1,258 @@
import * as THREE from 'three'
import { CELL, GRID, WALL_H, GameMap, type AABB } from './map'
import type { BodyPart, SoldierBody } from './types'
import { GRAVITY } from './types'
const HALF = GRID / 2
const STEP = 0.35 // max ledge height walkable without jumping
export interface HitboxDef { part: BodyPart; min: [number, number, number]; max: [number, number, number] }
/** Local space, feet at origin, facing -Z. */
export const HITBOXES: HitboxDef[] = [
{ part: 'head', min: [-0.15, 1.48, -0.16], max: [0.15, 1.8, 0.16] },
{ part: 'chest', min: [-0.24, 1.12, -0.15], max: [0.24, 1.48, 0.15] },
{ part: 'stomach', min: [-0.22, 0.86, -0.14], max: [0.22, 1.12, 0.14] },
{ part: 'arm', min: [-0.44, 1.0, -0.15], max: [-0.24, 1.48, 0.15] },
{ part: 'arm', min: [0.24, 1.0, -0.15], max: [0.44, 1.48, 0.15] },
{ part: 'leg', min: [-0.22, 0, -0.14], max: [-0.02, 0.9, 0.14] },
{ part: 'leg', min: [0.02, 0, -0.14], max: [0.22, 0.9, 0.14] },
]
export interface RayHit {
t: number
point: THREE.Vector3
kind: 'wall' | 'crate' | 'door' | 'floor' | 'soldier'
soldier?: SoldierBody
part?: BodyPart
}
export interface MovingBody {
pos: THREE.Vector3 // feet
vel: THREE.Vector3
onGround: boolean
radius: number
height: number
}
function rayAABB(ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, box: AABB): number | null {
let tmin = 0, tmax = Infinity
const o = [ox, oy, oz], d = [dx, dy, dz]
const mn = [box.min.x, box.min.y, box.min.z], mx = [box.max.x, box.max.y, box.max.z]
for (let i = 0; i < 3; i++) {
if (Math.abs(d[i]) < 1e-9) {
if (o[i] < mn[i] || o[i] > mx[i]) return null
} else {
let t1 = (mn[i] - o[i]) / d[i]
let t2 = (mx[i] - o[i]) / d[i]
if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp }
if (t1 > tmin) tmin = t1
if (t2 < tmax) tmax = t2
if (tmin > tmax) return null
}
}
return tmin
}
export class PhysicsWorld {
map: GameMap
constructor(map: GameMap) {
this.map = map
}
/** Is a circle at (x,z) with feet at y clear of walls/crates/doors? */
circleFree(x: number, z: number, r: number, feetY: number, ignoreDoors = false): boolean {
const cMin = Math.floor((x - r) / CELL + HALF), cMax = Math.floor((x + r) / CELL + HALF)
const rMin = Math.floor((z - r) / CELL + HALF), rMax = Math.floor((z + r) / CELL + HALF)
for (let cr = rMin; cr <= rMax; cr++) {
for (let cc = cMin; cc <= cMax; cc++) {
if (!this.map.isWall(cc, cr)) continue
if (this.circleHitsRect(x, z, r, (cc - HALF) * CELL, (cr - HALF) * CELL, (cc - HALF + 1) * CELL, (cr - HALF + 1) * CELL)) return false
}
}
for (const box of this.map.crates) {
if (box.max.y <= feetY + STEP) continue // can step onto / stand on it
if (this.circleHitsRect(x, z, r, box.min.x, box.min.z, box.max.x, box.max.z)) return false
}
if (!ignoreDoors) {
for (const d of this.map.doors) {
for (const box of d.colliders()) {
if (this.circleHitsRect(x, z, r, box.min.x, box.min.z, box.max.x, box.max.z)) return false
}
}
}
return true
}
private circleHitsRect(x: number, z: number, r: number, x1: number, z1: number, x2: number, z2: number): boolean {
const nx = Math.max(x1, Math.min(x, x2))
const nz = Math.max(z1, Math.min(z, z2))
const dx = x - nx, dz = z - nz
return dx * dx + dz * dz < r * r
}
/** Used by nav smoothing: walls + crates only (doors auto-open). */
walkClear(a: THREE.Vector3, b: THREE.Vector3, radius: number): boolean {
const dx = b.x - a.x, dz = b.z - a.z
const dist = Math.hypot(dx, dz)
const steps = Math.max(1, Math.ceil(dist / 0.35))
for (let i = 0; i <= steps; i++) {
const t = i / steps
if (!this.circleFree(a.x + dx * t, a.z + dz * t, radius, 0, true)) return false
}
return true
}
moveBody(body: MovingBody, wishWorld: THREE.Vector3, speed: number, jump: boolean, dt: number) {
const accel = body.onGround ? 22 : 4
const k = Math.min(1, accel * dt)
body.vel.x += (wishWorld.x * speed - body.vel.x) * k
body.vel.z += (wishWorld.z * speed - body.vel.z) * k
if (jump && body.onGround) {
body.vel.y = 7.0
body.onGround = false
}
// axis-separated horizontal collision
const nx = body.pos.x + body.vel.x * dt
if (this.circleFree(nx, body.pos.z, body.radius, body.pos.y)) body.pos.x = nx
else body.vel.x = 0
const nz = body.pos.z + body.vel.z * dt
if (this.circleFree(body.pos.x, nz, body.radius, body.pos.y)) body.pos.z = nz
else body.vel.z = 0
// vertical
const prevY = body.pos.y
body.vel.y -= GRAVITY * dt
body.pos.y += body.vel.y * dt
const ground = this.groundHeight(body.pos.x, body.pos.z, body.radius, prevY)
if (body.pos.y <= ground && body.vel.y <= 0) {
body.pos.y = ground
body.vel.y = 0
body.onGround = true
} else {
body.onGround = false
}
}
groundHeight(x: number, z: number, r: number, feetY: number): number {
let g = 0
for (const box of this.map.crates) {
if (box.max.y > feetY + STEP) continue
if (this.circleHitsRect(x, z, r, box.min.x, box.min.z, box.max.x, box.max.z)) {
if (box.max.y > g) g = box.max.y
}
}
return g
}
raycast(
origin: THREE.Vector3,
dir: THREE.Vector3,
maxDist: number,
soldiers?: SoldierBody[],
ignoreId?: number,
ignoreTeam?: string,
): RayHit | null {
let best: RayHit | null = null
const consider = (t: number, kind: RayHit['kind'], soldier?: SoldierBody, part?: BodyPart) => {
if (t < 0 || t > maxDist) return
if (best && t >= best.t) return
best = {
t, kind, soldier, part,
point: new THREE.Vector3(origin.x + dir.x * t, origin.y + dir.y * t, origin.z + dir.z * t),
}
}
// floor
if (dir.y < -1e-6) consider(-origin.y / dir.y, 'floor')
// walls via DDA
const wt = this.wallRay(origin, dir, maxDist)
if (wt !== null) consider(wt, 'wall')
// crates
for (const box of this.map.crates) {
const t = rayAABB(origin.x, origin.y, origin.z, dir.x, dir.y, dir.z, box)
if (t !== null) consider(t, 'crate')
}
// door panels
for (const d of this.map.doors) {
for (const box of d.colliders()) {
const t = rayAABB(origin.x, origin.y, origin.z, dir.x, dir.y, dir.z, box)
if (t !== null) consider(t, 'door')
}
}
// soldier hitboxes (ray transformed into each soldier's local yaw space)
if (soldiers) {
for (const s of soldiers) {
if (!s.alive || s.id === ignoreId) continue
if (ignoreTeam && s.team === ignoreTeam) continue
const cos = Math.cos(-s.yaw), sin = Math.sin(-s.yaw)
const rx = origin.x - s.pos.x, rz = origin.z - s.pos.z
const lox = rx * cos - rz * sin
const loz = rx * sin + rz * cos
const loy = origin.y - s.pos.y
const ldx = dir.x * cos - dir.z * sin
const ldz = dir.x * sin + dir.z * cos
for (const hb of HITBOXES) {
const t = rayAABB(lox, loy, loz, ldx, dir.y, ldz, {
min: new THREE.Vector3(hb.min[0], hb.min[1], hb.min[2]),
max: new THREE.Vector3(hb.max[0], hb.max[1], hb.max[2]),
})
if (t !== null) consider(t, 'soldier', s, hb.part)
}
}
}
return best
}
private wallRay(origin: THREE.Vector3, dir: THREE.Vector3, maxDist: number): number | null {
let cx = Math.floor(origin.x / CELL + HALF)
let cz = Math.floor(origin.z / CELL + HALF)
const stepX = dir.x > 0 ? 1 : -1
const stepZ = dir.z > 0 ? 1 : -1
const tDeltaX = Math.abs(dir.x) > 1e-9 ? CELL / Math.abs(dir.x) : Infinity
const tDeltaZ = Math.abs(dir.z) > 1e-9 ? CELL / Math.abs(dir.z) : Infinity
const nextGridX = (cx - HALF + (stepX > 0 ? 1 : 0)) * CELL
const nextGridZ = (cz - HALF + (stepZ > 0 ? 1 : 0)) * CELL
let tMaxX = Math.abs(dir.x) > 1e-9 ? (nextGridX - origin.x) / dir.x : Infinity
let tMaxZ = Math.abs(dir.z) > 1e-9 ? (nextGridZ - origin.z) / dir.z : Infinity
if (this.map.isWall(cx, cz)) return 0
let t = 0
for (let i = 0; i < GRID * 3; i++) {
if (tMaxX < tMaxZ) {
t = tMaxX
tMaxX += tDeltaX
cx += stepX
} else {
t = tMaxZ
tMaxZ += tDeltaZ
cz += stepZ
}
if (t > maxDist) return null
if (this.map.isWall(cx, cz)) {
const y = origin.y + dir.y * t
if (y >= 0 && y <= WALL_H) return t
}
}
return null
}
/** Line of sight between two points against static world only. */
hasLOS(a: THREE.Vector3, b: THREE.Vector3): boolean {
const dir = new THREE.Vector3().subVectors(b, a)
const dist = dir.length()
if (dist < 1e-4) return true
dir.multiplyScalar(1 / dist)
const hit = this.raycast(a, dir, dist * 0.99)
return hit === null
}
}
@@ -0,0 +1,116 @@
import * as THREE from 'three'
import { CharacterRig } from './character'
import { WEAPONS, WeaponState } from './weapons'
import {
BODY_HEIGHT, BODY_RADIUS, EYE_HEIGHT,
emptyControl, type Control, type Slot, type Team, type WeaponId,
} from './types'
import type { AIBrain } from './ai'
export class Soldier {
id: number
name: string
originalName: string
team: Team
isPlayer = false
pos = new THREE.Vector3()
vel = new THREE.Vector3()
yaw = 0
pitch = 0
onGround = true
radius = BODY_RADIUS
height = BODY_HEIGHT
hp = 100
armor = 0
alive = true
kills = 0
deaths = 0
weapons: Partial<Record<Slot, WeaponState>> = {}
slot: Slot = 'secondary'
scoped = false
switchCooldown = 0
hasBomb = false
channel: { kind: 'plant' | 'defuse'; t: number; total: number } | null = null
control: Control = emptyControl()
brain: AIBrain | null = null
rig: CharacterRig
recoilPitch = 0
recoilYaw = 0
stepAcc = 0
/** buffered semi-auto trigger: a click stays valid for a short window */
tapBuffer = 0
lastSeenBy: Record<Team, number> = { CT: -99, T: -99 }
wasReloading = false
constructor(id: number, name: string, team: Team) {
this.id = id
this.name = name
this.originalName = name
this.team = team
this.rig = new CharacterRig(team)
}
get weapon(): WeaponState {
return this.weapons[this.slot] ?? this.weapons.knife!
}
get eyePos(): THREE.Vector3 {
return new THREE.Vector3(this.pos.x, this.pos.y + EYE_HEIGHT, this.pos.z)
}
/** View direction including recoil offset (recoil affects both camera and bullets). */
viewDir(): THREE.Vector3 {
const yaw = this.yaw + this.recoilYaw
const pitch = THREE.MathUtils.clamp(this.pitch + this.recoilPitch, -1.55, 1.55)
const cp = Math.cos(pitch)
return new THREE.Vector3(-Math.sin(yaw) * cp, Math.sin(pitch), -Math.cos(yaw) * cp)
}
rightDir(): THREE.Vector3 {
return new THREE.Vector3(Math.cos(this.yaw), 0, -Math.sin(this.yaw))
}
equip(primary: WeaponId | null, secondary: WeaponId, armor: number) {
this.weapons = { knife: new WeaponState(WEAPONS.knife) }
this.weapons.secondary = new WeaponState(WEAPONS[secondary])
if (primary) this.weapons.primary = new WeaponState(WEAPONS[primary])
this.slot = primary ? 'primary' : 'secondary'
this.armor = armor
this.scoped = false
this.rig.setWeapon(this.weapon.def.id)
}
respawn(pos: THREE.Vector3, yaw: number) {
this.pos.copy(pos)
this.vel.set(0, 0, 0)
this.yaw = yaw
this.pitch = 0
this.hp = 100
this.alive = true
this.onGround = true
this.channel = null
this.hasBomb = false
this.recoilPitch = 0
this.recoilYaw = 0
this.scoped = false
this.control = emptyControl()
this.rig.spawnReset()
this.rig.setBomb(false)
}
trySwitch(slot: Slot): boolean {
if (!this.alive || slot === this.slot || !this.weapons[slot]) return false
this.slot = slot
this.scoped = false
this.switchCooldown = 0.4
this.weapon.cooldown = Math.max(this.weapon.cooldown, 0.4)
this.rig.setWeapon(this.weapon.def.id)
return true
}
}
@@ -0,0 +1,53 @@
import type { HudState } from './types'
export function initialHud(): HudState {
return {
phase: 'menu',
locked: false,
auto: false,
round: 1,
pistolRound: true,
ctScore: 0,
tScore: 0,
timeLeft: 0,
bombTimeLeft: null,
bombPlanted: false,
freezeLeft: 0,
winner: null,
winReason: '',
matchWinner: null,
playerTeam: 'CT',
loadout: 'rifle',
me: {
name: '你', team: 'CT', hp: 100, armor: 0, alive: true,
weaponName: '', weaponId: 'knife', mag: 0, reserve: 0,
reloading: false, scoped: false, hasBomb: false, spreadPx: 4,
},
spectating: null,
channel: null,
centerHint: null,
killfeed: [],
hitmarkerAt: -99,
hitmarkerHead: false,
damageAt: -99,
}
}
/** Tiny external store bridging the game loop and React (useSyncExternalStore). */
export class HudStore {
state: HudState = initialHud()
private version = 0
private listeners = new Set<() => void>()
subscribe = (fn: () => void) => {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
}
getSnapshot = () => this.version
bump() {
this.version++
for (const fn of this.listeners) fn()
}
}
@@ -0,0 +1,106 @@
import * as THREE from 'three'
export type Team = 'CT' | 'T'
export type Slot = 'primary' | 'secondary' | 'knife'
export type BodyPart = 'head' | 'chest' | 'stomach' | 'arm' | 'leg'
export type WeaponId = 'ak47' | 'm4a4' | 'awp' | 'glock' | 'usp' | 'deagle' | 'knife'
export type Phase = 'menu' | 'freeze' | 'live' | 'over' | 'matchover'
export type Loadout = 'rifle' | 'awp'
/** Per-tick intent, produced by player input or an AI brain, consumed by physics/combat. */
export interface Control {
moveX: number // strafe, +right
moveZ: number // +forward
jump: boolean
walk: boolean
fire: boolean // held
fireTap: boolean // edge-triggered (semi-auto)
altTap: boolean // scope toggle
reload: boolean
use: boolean // E held: plant / defuse
slot: Slot | null
}
export function emptyControl(): Control {
return {
moveX: 0, moveZ: 0, jump: false, walk: false,
fire: false, fireTap: false, altTap: false,
reload: false, use: false, slot: null,
}
}
export interface KillEntry {
id: number
attacker: string
attackerTeam: Team
victim: string
victimTeam: Team
weapon: string
headshot: boolean
t: number
}
export interface HudState {
phase: Phase
locked: boolean
auto: boolean
round: number
pistolRound: boolean
ctScore: number
tScore: number
timeLeft: number
bombTimeLeft: number | null
bombPlanted: boolean
freezeLeft: number
winner: Team | null
winReason: string
matchWinner: Team | null
playerTeam: Team
loadout: Loadout
me: {
name: string
team: Team
hp: number
armor: number
alive: boolean
weaponName: string
weaponId: WeaponId
mag: number
reserve: number
reloading: boolean
scoped: boolean
hasBomb: boolean
spreadPx: number
}
spectating: { name: string; isSelf: boolean } | null
channel: { label: string; progress: number } | null
centerHint: string | null
killfeed: KillEntry[]
hitmarkerAt: number
hitmarkerHead: boolean
damageAt: number
}
/** Minimal body info physics needs for hitbox raycasts (avoids circular imports). */
export interface SoldierBody {
id: number
team: Team
alive: boolean
pos: THREE.Vector3
yaw: number
}
export const RUN_SPEED = 4.7
export const GRAVITY = 20
export const JUMP_VEL = 7.0
export const EYE_HEIGHT = 1.62
export const BODY_RADIUS = 0.4
export const BODY_HEIGHT = 1.8
export const PLANT_TIME = 3.2
export const DEFUSE_TIME = 7.0
export const BOMB_FUSE = 40
export const ROUND_TIME = 115
export const FREEZE_TIME = 4
export const ROUND_END_TIME = 5
export const WIN_SCORE = 13
@@ -0,0 +1,202 @@
import * as THREE from 'three'
import type { WeaponId } from './types'
function mat(color: number, metalness = 0.2): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({ color, roughness: 0.6, metalness })
}
function box(w: number, h: number, d: number, m: THREE.Material): THREE.Mesh {
return new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m)
}
/** First-person weapon models, attached to the camera. Procedural geometry only. */
export class ViewModel {
group = new THREE.Group()
private models = new Map<WeaponId, THREE.Group>()
private current: WeaponId = 'knife'
private kick = 0
private raise = 1
private slashT = 0
private reloadT = 0
private reloadDur = 0
private bobPhase = 0
private flash: THREE.Mesh
private flashT = 0
constructor(camera: THREE.Camera) {
this.group.position.set(0.26, -0.24, -0.5)
this.group.scale.setScalar(0.82)
camera.add(this.group)
const skin = mat(0xd8b094, 0)
for (const id of ['ak47', 'm4a4', 'awp', 'glock', 'usp', 'deagle', 'knife'] as WeaponId[]) {
const g = this.build(id)
// hands
const handL = box(0.05, 0.05, 0.07, skin)
const handR = box(0.05, 0.05, 0.07, skin)
if (id === 'knife') {
handR.position.set(0, -0.03, 0.1)
g.add(handR)
} else {
handL.position.set(-0.01, -0.04, -0.22)
handR.position.set(0.01, -0.05, 0.05)
g.add(handL, handR)
}
g.visible = false
this.group.add(g)
this.models.set(id, g)
}
this.flash = new THREE.Mesh(
new THREE.PlaneGeometry(0.16, 0.16),
new THREE.MeshBasicMaterial({ color: 0xffd080, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false }),
)
this.flash.position.set(0, 0.02, -0.85)
this.group.add(this.flash)
this.setWeapon('knife', false)
}
private build(id: WeaponId): THREE.Group {
const g = new THREE.Group()
const dark = mat(0x35353a)
const darker = mat(0x232326)
switch (id) {
case 'ak47': {
const wood = mat(0x6f4c2e, 0)
const recv = box(0.055, 0.09, 0.42, dark); recv.position.z = -0.1
const guard = box(0.055, 0.07, 0.2, wood); guard.position.set(0, -0.005, -0.38)
const barrel = box(0.025, 0.025, 0.3, darker); barrel.position.z = -0.6
const sight = box(0.012, 0.05, 0.012, darker); sight.position.set(0, 0.06, -0.68)
const magM = box(0.04, 0.18, 0.1, mat(0x8a6a3a)); magM.position.set(0, -0.13, -0.02); magM.rotation.x = 0.45
const stock = box(0.045, 0.08, 0.2, wood); stock.position.set(0, -0.01, 0.2)
g.add(recv, guard, barrel, sight, magM, stock)
break
}
case 'm4a4': {
const recv = box(0.055, 0.09, 0.44, dark); recv.position.z = -0.12
const rail = box(0.03, 0.03, 0.3, darker); rail.position.set(0, 0.065, -0.2)
const barrel = box(0.024, 0.024, 0.32, darker); barrel.position.z = -0.62
const magM = box(0.04, 0.16, 0.08, darker); magM.position.set(0, -0.12, -0.06)
const stock = box(0.05, 0.075, 0.18, dark); stock.position.set(0, 0, 0.2)
const grip = box(0.03, 0.09, 0.045, darker); grip.position.set(0, -0.09, 0.08); grip.rotation.x = 0.3
g.add(recv, rail, barrel, magM, stock, grip)
break
}
case 'awp': {
const olive = mat(0x4c5f3b, 0)
const body = box(0.055, 0.095, 0.6, olive); body.position.z = -0.15
const barrel = box(0.022, 0.022, 0.5, darker); barrel.position.z = -0.78
const muzzleBrake = box(0.035, 0.035, 0.06, darker); muzzleBrake.position.z = -1.0
const scope = new THREE.Mesh(new THREE.CylinderGeometry(0.032, 0.032, 0.26, 10), darker)
scope.rotation.x = Math.PI / 2
scope.position.set(0, 0.085, -0.15)
const stock = box(0.05, 0.1, 0.22, olive); stock.position.set(0, -0.01, 0.2)
const bolt = box(0.015, 0.015, 0.08, mat(0xa8a8b0, 0.7)); bolt.position.set(0.045, 0.03, 0.02)
g.add(body, barrel, muzzleBrake, scope, stock, bolt)
break
}
case 'glock': {
const slide = box(0.04, 0.055, 0.22, dark); slide.position.z = -0.05
const frame = box(0.038, 0.05, 0.18, darker); frame.position.set(0, -0.045, -0.02)
const grip = box(0.036, 0.12, 0.055, darker); grip.position.set(0, -0.1, 0.05); grip.rotation.x = 0.25
g.add(slide, frame, grip)
break
}
case 'usp': {
const slide = box(0.04, 0.055, 0.26, mat(0x4a4a52, 0.5)); slide.position.z = -0.07
const supp = box(0.045, 0.045, 0.14, darker); supp.position.z = -0.27
const grip = box(0.038, 0.12, 0.055, darker); grip.position.set(0, -0.1, 0.05); grip.rotation.x = 0.22
g.add(slide, supp, grip)
break
}
case 'deagle': {
const silver = mat(0xb4b4bc, 0.8)
const slide = box(0.05, 0.07, 0.3, silver); slide.position.z = -0.08
const grip = box(0.04, 0.13, 0.06, darker); grip.position.set(0, -0.1, 0.06); grip.rotation.x = 0.25
g.add(slide, grip)
break
}
case 'knife': {
const blade = box(0.012, 0.04, 0.26, mat(0xd6dde4, 0.9)); blade.position.z = -0.16
const edge = box(0.006, 0.012, 0.24, mat(0xf2f6fa, 1)); edge.position.set(0, -0.026, -0.15)
const guardK = box(0.04, 0.015, 0.02, darker); guardK.position.z = -0.02
const handle = box(0.028, 0.045, 0.13, mat(0x2c2c30, 0)); handle.position.z = 0.06
g.add(blade, edge, guardK, handle)
g.rotation.z = 0.25
g.rotation.y = 0.28
break
}
}
return g
}
setWeapon(id: WeaponId, animate = true) {
if (id === this.current && animate) return
this.current = id
for (const [k, g] of this.models) g.visible = k === id
if (animate) this.raise = 0
this.reloadT = 0
this.reloadDur = 0
}
onFire(kickScale: number) {
if (this.current === 'knife') {
this.slashT = 0.3
return
}
this.kick = Math.min(1.6, this.kick + 0.5 + kickScale * 18)
this.flashT = 0.05
if (this.current !== 'awp') {
;(this.flash.material as THREE.MeshBasicMaterial).opacity = 0.9
}
}
onReload(duration: number) {
this.reloadDur = duration
this.reloadT = 0.0001
}
update(dt: number, speed: number, onGround: boolean, scoped: boolean) {
this.kick *= Math.exp(-13 * dt)
this.raise = Math.min(1, this.raise + dt / 0.28)
this.flashT -= dt
if (this.flashT <= 0) (this.flash.material as THREE.MeshBasicMaterial).opacity = 0
if (this.reloadDur > 0) {
this.reloadT += dt
if (this.reloadT >= this.reloadDur) { this.reloadT = 0; this.reloadDur = 0 }
}
this.group.visible = !scoped
const speedRatio = onGround ? Math.min(1, speed / 4.7) : 0.2
this.bobPhase += speed * dt * 1.9
let px = 0.26, py = -0.24, pz = -0.5
let rx = 0, ry = 0, rz = 0
py += Math.abs(Math.sin(this.bobPhase)) * 0.012 * speedRatio - 0.006 * speedRatio
px += Math.sin(this.bobPhase * 0.5) * 0.008 * speedRatio
pz += this.kick * 0.05
rx += this.kick * 0.06
py -= (1 - this.raise) * 0.3
rx -= (1 - this.raise) * 0.6
if (this.reloadDur > 0) {
const p = Math.sin(Math.PI * Math.min(1, this.reloadT / this.reloadDur))
py -= p * 0.09
rz += p * 0.5
rx -= p * 0.25
}
if (this.slashT > 0) {
this.slashT = Math.max(0, this.slashT - dt)
const p = 1 - this.slashT / 0.3 // 0 → 1 over the swing
const sweep = Math.sin(p * Math.PI)
// diagonal slash: wind up right, cut across to lower-left
px += 0.1 - p * 0.32
py += 0.06 - p * 0.2
pz -= sweep * 0.22
ry += 0.5 - p * 1.1
rz -= sweep * 0.9
rx -= sweep * 0.35
}
this.group.position.set(px, py, pz)
this.group.rotation.set(rx, ry, rz)
}
}
@@ -0,0 +1,141 @@
import type { BodyPart, Slot, WeaponId } from './types'
export interface WeaponDef {
id: WeaponId
name: string
slot: Slot
damage: number
auto: boolean
rpm: number
magSize: number
reserve: number
reloadTime: number
/** standing-still base inaccuracy, radians */
spreadBase: number
/** extra inaccuracy per shot fired (bloom) */
spreadPerShot: number
/** exponential bloom decay rate, 1/s */
spreadDecay: number
/** extra spread at full run speed */
spreadMove: number
/** camera kick per shot, radians */
kickPitch: number
kickYaw: number
moveMult: number
/** damage kept when target has armor (higher = better penetration) */
armorPen: number
sound: 'ak' | 'm4' | 'awp' | 'pistol' | 'glock' | 'deagle' | 'knife'
scopeFov?: number
spreadScoped?: number
range?: number
}
export const WEAPONS: Record<WeaponId, WeaponDef> = {
ak47: {
id: 'ak47', name: 'AK-47', slot: 'primary',
damage: 36, auto: true, rpm: 600, magSize: 30, reserve: 90, reloadTime: 2.4,
spreadBase: 0.006, spreadPerShot: 0.011, spreadDecay: 5.5, spreadMove: 0.045,
kickPitch: 0.019, kickYaw: 0.006, moveMult: 0.92, armorPen: 0.775, sound: 'ak',
},
m4a4: {
id: 'm4a4', name: 'M4A4', slot: 'primary',
damage: 33, auto: true, rpm: 666, magSize: 30, reserve: 90, reloadTime: 2.1,
spreadBase: 0.005, spreadPerShot: 0.007, spreadDecay: 6.5, spreadMove: 0.04,
kickPitch: 0.009, kickYaw: 0.004, moveMult: 0.94, armorPen: 0.7, sound: 'm4',
},
awp: {
id: 'awp', name: 'AWP', slot: 'primary',
damage: 115, auto: false, rpm: 41, magSize: 5, reserve: 30, reloadTime: 3.6,
spreadBase: 0.07, spreadPerShot: 0.01, spreadDecay: 3, spreadMove: 0.09,
kickPitch: 0.032, kickYaw: 0.004, moveMult: 0.84, armorPen: 0.975, sound: 'awp',
scopeFov: 20, spreadScoped: 0.0012,
},
glock: {
id: 'glock', name: 'Glock-18', slot: 'secondary',
damage: 26, auto: false, rpm: 400, magSize: 20, reserve: 120, reloadTime: 2.2,
spreadBase: 0.009, spreadPerShot: 0.012, spreadDecay: 7, spreadMove: 0.02,
kickPitch: 0.008, kickYaw: 0.003, moveMult: 1.0, armorPen: 0.47, sound: 'glock',
},
usp: {
id: 'usp', name: 'USP-S', slot: 'secondary',
damage: 33, auto: false, rpm: 352, magSize: 12, reserve: 24, reloadTime: 2.2,
spreadBase: 0.007, spreadPerShot: 0.011, spreadDecay: 7, spreadMove: 0.02,
kickPitch: 0.009, kickYaw: 0.003, moveMult: 1.0, armorPen: 0.505, sound: 'pistol',
},
deagle: {
id: 'deagle', name: 'Desert Eagle', slot: 'secondary',
damage: 53, auto: false, rpm: 267, magSize: 7, reserve: 35, reloadTime: 2.2,
spreadBase: 0.012, spreadPerShot: 0.042, spreadDecay: 4.5, spreadMove: 0.05,
kickPitch: 0.028, kickYaw: 0.008, moveMult: 0.96, armorPen: 0.8, sound: 'deagle',
},
knife: {
id: 'knife', name: '军刀', slot: 'knife',
damage: 45, auto: true, rpm: 120, magSize: 0, reserve: 0, reloadTime: 0,
spreadBase: 0, spreadPerShot: 0, spreadDecay: 1, spreadMove: 0,
kickPitch: 0.004, kickYaw: 0, moveMult: 1.05, armorPen: 0.85, sound: 'knife',
range: 2.2,
},
}
/** 头部伤害为身体(胸)的两倍,其余部位各不相同。 */
export const PART_MULT: Record<BodyPart, number> = {
head: 2.0,
chest: 1.0,
stomach: 1.25,
arm: 0.85,
leg: 0.7,
}
export class WeaponState {
def: WeaponDef
mag: number
reserve: number
cooldown = 0
bloom = 0
reloadLeft = 0
constructor(def: WeaponDef) {
this.def = def
this.mag = def.magSize
this.reserve = def.reserve
}
get reloading() { return this.reloadLeft > 0 }
get isKnife() { return this.def.id === 'knife' }
/** Returns true the tick a reload completes. */
update(dt: number): boolean {
if (this.cooldown > 0) this.cooldown -= dt
this.bloom *= Math.exp(-this.def.spreadDecay * dt)
if (this.reloadLeft > 0) {
this.reloadLeft -= dt
if (this.reloadLeft <= 0) {
this.reloadLeft = 0
const take = Math.min(this.def.magSize - this.mag, this.reserve)
this.mag += take
this.reserve -= take
this.bloom = 0
return true
}
}
return false
}
canFire(): boolean {
if (this.reloading || this.cooldown > 0) return false
return this.isKnife || this.mag > 0
}
onFire() {
if (!this.isKnife) this.mag--
this.cooldown = 60 / this.def.rpm
this.bloom = Math.min(this.bloom + this.def.spreadPerShot, 0.08)
}
startReload(): boolean {
if (this.isKnife || this.reloading) return false
if (this.mag >= this.def.magSize || this.reserve <= 0) return false
this.reloadLeft = this.def.reloadTime
return true
}
}