Files
nagi-bench/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/engine.ts
T
Jesse ZhangandClaude Opus 4.8 cbfce3b7cc 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>
2026-07-14 00:08:28 +01:00

1107 lines
39 KiB
TypeScript

import * as THREE from 'three'
import { AIBrain, type AIWorld } from './ai'
import { AudioMan } from './audio'
import { Effects } from './effects'
import { GameMap } from './map'
import { Nav } from './nav'
import { PhysicsWorld } from './physics'
import { Soldier } from './soldier'
import { HudStore } from './store'
import { ViewModel } from './viewmodel'
import { PART_MULT, WEAPONS } from './weapons'
import {
BOMB_FUSE, DEFUSE_TIME, FREEZE_TIME, PLANT_TIME,
ROUND_END_TIME, ROUND_TIME, RUN_SPEED, WIN_SCORE,
type BodyPart, type Loadout, type Phase, type Slot, type Team, type WeaponId,
} from './types'
const CT_NAMES = ['Rock', 'Duke', 'Nova', 'Ice', 'Sarge']
const T_NAMES = ['Viper', 'Wolf', 'Ghost', 'Blade', 'Fox']
const FIXED_DT = 1 / 64
interface BombState {
carrierId: number | null
droppedPos: THREE.Vector3 | null
plantedPos: THREE.Vector3 | null
plantedAt: number
exploded: boolean
}
export class Engine {
// three
private renderer: THREE.WebGLRenderer
private scene = new THREE.Scene()
private camera = new THREE.PerspectiveCamera(75, 1, 0.05, 400)
private container: HTMLElement
// world
map: GameMap
private physics: PhysicsWorld
private nav: Nav
private effects: Effects
audio = new AudioMan()
private viewmodel: ViewModel
private bombMesh: THREE.Group
// game state
soldiers: Soldier[] = []
playerTeam: Team = 'CT'
private playerId = 0
private controlledId = 0
viewedId = 0
private autoMode = false
phase: Phase = 'menu'
private round = 1
private ctScore = 0
private tScore = 0
private phaseEndsAt = 0
private roundEndsAt = 0
private winner: Team | null = null
private winReason = ''
private matchWinner: Team | null = null
bomb: BombState = { carrierId: null, droppedPos: null, plantedPos: null, plantedAt: 0, exploded: false }
private nextBeepAt = 0
private nextDefTickAt = 0
playerLoadout: Loadout = 'rifle'
// timing
time = 0
private accumulator = 0
private lastFrame = 0
private rafId = 0
private hudSyncAt = 0
private seenScanAt = 0
private disposed = false
// input
private keys = new Set<string>()
private mouseFire = false
private fireTapQueued = false
private altTapQueued = false
private jumpQueued = false
private slotQueued: Slot | null = null
private deathAt = 0
pointerLocked = false
hud = new HudStore()
private killfeedId = 0
constructor(container: HTMLElement) {
this.container = container
this.renderer = new THREE.WebGLRenderer({ antialias: true })
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1.15
this.renderer.setClearColor(0xb9d2e8)
container.appendChild(this.renderer.domElement)
this.scene.fog = new THREE.Fog(0xc4d6e6, 70, 240)
const hemi = new THREE.HemisphereLight(0xcfe4f5, 0x8a7a55, 1.5)
const sun = new THREE.DirectionalLight(0xfff2dd, 2.2)
sun.position.set(60, 90, 30)
sun.castShadow = true
sun.shadow.mapSize.set(2048, 2048)
sun.shadow.camera.left = -90
sun.shadow.camera.right = 90
sun.shadow.camera.top = 90
sun.shadow.camera.bottom = -90
sun.shadow.camera.far = 260
sun.shadow.bias = -0.0006
this.scene.add(hemi, sun, sun.target)
this.map = new GameMap()
this.scene.add(this.map.group)
this.physics = new PhysicsWorld(this.map)
this.nav = new Nav(this.map, this.physics)
this.effects = new Effects(this.scene)
this.camera.rotation.order = 'YXZ'
this.scene.add(this.camera)
this.viewmodel = new ViewModel(this.camera)
this.bombMesh = this.buildBombMesh()
this.bombMesh.visible = false
this.scene.add(this.bombMesh)
this.bindEvents()
this.resize()
this.lastFrame = performance.now()
const loop = (t: number) => {
if (this.disposed) return
this.rafId = requestAnimationFrame(loop)
const dt = Math.min(0.05, (t - this.lastFrame) / 1000)
this.lastFrame = t
this.update(dt)
}
this.rafId = requestAnimationFrame(loop)
}
private buildBombMesh(): THREE.Group {
const g = new THREE.Group()
const body = new THREE.Mesh(
new THREE.BoxGeometry(0.42, 0.16, 0.3),
new THREE.MeshStandardMaterial({ color: 0x8a7455, roughness: 0.8 }),
)
body.position.y = 0.08
body.castShadow = true
const panel = new THREE.Mesh(
new THREE.BoxGeometry(0.2, 0.04, 0.12),
new THREE.MeshStandardMaterial({ color: 0x1a1f18 }),
)
panel.position.set(0, 0.18, 0)
const led = new THREE.Mesh(
new THREE.BoxGeometry(0.05, 0.03, 0.05),
new THREE.MeshBasicMaterial({ color: 0xff2222 }),
)
led.position.set(0.12, 0.19, 0)
led.name = 'led'
g.add(body, panel, led)
return g
}
// ---------------------------------------------------------------- lifecycle
start(team: Team, auto = false) {
this.audio.resume()
this.playerTeam = team
this.autoMode = auto
this.ctScore = 0
this.tScore = 0
this.matchWinner = null
for (const s of this.soldiers) s.rig.dispose(this.scene)
this.soldiers = []
let id = 0
for (const t of ['CT', 'T'] as Team[]) {
const names = t === 'CT' ? CT_NAMES : T_NAMES
for (let i = 0; i < 5; i++) {
const isPlayer = !auto && t === team && i === 0
const s = new Soldier(id, isPlayer ? '你' : names[i], t)
s.isPlayer = isPlayer
if (!isPlayer) s.brain = new AIBrain(s)
if (isPlayer) this.playerId = id
this.scene.add(s.rig.root)
this.soldiers.push(s)
id++
}
}
if (auto) this.playerId = 0
this.controlledId = this.playerId
this.viewedId = this.playerId
this.startRound(1)
}
toMenu() {
this.phase = 'menu'
for (const s of this.soldiers) s.rig.dispose(this.scene)
this.soldiers = []
this.bombMesh.visible = false
document.exitPointerLock?.()
this.syncHud()
}
restartMatch() {
this.start(this.playerTeam, this.autoMode)
}
requestLock() {
this.renderer.domElement.requestPointerLock()
}
dispose() {
this.disposed = true
cancelAnimationFrame(this.rafId)
this.unbindEvents()
this.renderer.dispose()
this.renderer.domElement.remove()
}
// ------------------------------------------------------------------ rounds
private startRound(n: number) {
this.round = n
this.phase = 'freeze'
this.phaseEndsAt = this.time + FREEZE_TIME
this.winner = null
this.winReason = ''
this.bomb = { carrierId: null, droppedPos: null, plantedPos: null, plantedAt: 0, exploded: false }
this.bombMesh.visible = false
this.controlledId = this.playerId
this.viewedId = this.playerId
const pistol = n === 1
// restore bots that were taken over in a previous round
for (const s of this.soldiers) {
if (s.id !== this.playerId) {
s.isPlayer = false
s.name = s.originalName
if (!s.brain) s.brain = new AIBrain(s)
} else {
s.isPlayer = !this.autoMode
}
}
const spawnIdx: Record<Team, number> = { CT: 0, T: 0 }
// one AI per team may carry an AWP on gun rounds
const awpBot: Record<Team, number> = { CT: -1, T: -1 }
if (!pistol) {
for (const t of ['CT', 'T'] as Team[]) {
if (Math.random() < 0.45) {
const bots = this.soldiers.filter(s => s.team === t && !s.isPlayer)
awpBot[t] = bots[Math.floor(Math.random() * bots.length)].id
}
}
}
for (const s of this.soldiers) {
const spawn = this.map.spawns[s.team][spawnIdx[s.team]++ % 5]
s.respawn(spawn.pos.clone(), spawn.yaw)
const secondary: WeaponId = pistol ? (s.team === 'T' ? 'glock' : 'usp') : 'deagle'
let primary: WeaponId | null = null
if (!pistol) {
primary = s.team === 'T' ? 'ak47' : 'm4a4'
if (s.id === awpBot[s.team]) primary = 'awp'
if (s.id === this.playerId && !this.autoMode && this.playerLoadout === 'awp') primary = 'awp'
}
s.equip(primary, secondary, pistol ? 0 : 100)
s.brain?.reset()
}
// hand C4 to a random T
const ts = this.soldiers.filter(s => s.team === 'T')
const carrier = ts[Math.floor(Math.random() * ts.length)]
carrier.hasBomb = true
carrier.rig.setBomb(true)
this.bomb.carrierId = carrier.id
this.viewmodel.setWeapon(this.controlled?.weapon.def.id ?? 'knife', false)
this.audio.roundStart()
if (this.autoMode) console.log(`[round ${n}] start — CT ${this.ctScore} : ${this.tScore} T`)
this.syncHud()
}
private goLive() {
this.phase = 'live'
this.roundEndsAt = this.time + ROUND_TIME
this.assignMissions()
}
private assignMissions() {
const site: 'A' | 'B' = Math.random() < 0.55 ? 'A' : 'B'
const routes = this.map.routes[site]
const sitePos = this.map.anchors[site === 'A' ? 'aSite' : 'bSite']
const ts = this.soldiers.filter(s => s.team === 'T' && s.brain)
const carrierRoute = Math.floor(Math.random() * routes.length)
ts.forEach((s, i) => {
const route = routes[(carrierRoute + (i % 2 === 0 ? 0 : 1)) % routes.length]
const isCarrier = s.hasBomb
const final = isCarrier ? this.map.randomSiteSpot(site) : this.map.randomSpotNear(sitePos, 9)
s.brain!.setMission(route, final, this.map.anchors.ctSpawn, isCarrier ? 'plant' : 'none')
})
const cts = this.soldiers.filter(s => s.team === 'CT' && s.brain)
const spots = [
...this.map.holdSpots.A.slice(0, 2).map(h => ({ ...h })),
...this.map.holdSpots.B.slice(0, 2).map(h => ({ ...h })),
...this.map.holdSpots.mid.slice(0, 1).map(h => ({ ...h })),
]
cts.forEach((s, i) => {
const spot = spots[i % spots.length]
s.brain!.setMission([], spot.pos, spot.look, 'none')
})
}
private winRound(team: Team, reason: string) {
if (this.phase !== 'live') return
this.phase = 'over'
this.phaseEndsAt = this.time + ROUND_END_TIME
this.winner = team
this.winReason = reason
if (team === 'CT') this.ctScore++
else this.tScore++
this.audio.roundEnd(this.autoMode ? true : team === this.playerTeam)
if (this.autoMode) console.log(`[round ${this.round}] winner=${team} reason=${reason} — CT ${this.ctScore} : ${this.tScore} T`)
this.syncHud()
}
private checkElimination() {
if (this.phase !== 'live') return
const ctAlive = this.soldiers.some(s => s.team === 'CT' && s.alive)
const tAlive = this.soldiers.some(s => s.team === 'T' && s.alive)
if (!ctAlive) this.winRound('T', '歼灭敌方')
else if (!tAlive && !this.bomb.plantedPos) this.winRound('CT', '歼灭敌方')
// T 全灭但已下包 → 等待 C4 结算
}
// ------------------------------------------------------------------- input
private onKeyDown = (e: KeyboardEvent) => {
if (e.repeat) return
this.keys.add(e.code)
if (e.code === 'Space') { this.jumpQueued = true; e.preventDefault() }
if (e.code === 'Digit1') this.slotQueued = 'primary'
if (e.code === 'Digit2') this.slotQueued = 'secondary'
if (e.code === 'Digit3') this.slotQueued = 'knife'
if (e.code === 'KeyR') { /* handled per tick via keys */ }
if (e.code === 'KeyB' && this.phase === 'freeze' && this.round > 1) {
this.playerLoadout = this.playerLoadout === 'rifle' ? 'awp' : 'rifle'
const p = this.soldiers[this.playerId]
if (p && !this.autoMode) {
const primary: WeaponId = this.playerLoadout === 'awp' ? 'awp' : (p.team === 'T' ? 'ak47' : 'm4a4')
p.equip(primary, 'deagle', p.armor)
this.viewmodel.setWeapon(p.weapon.def.id)
}
this.syncHud()
}
// spectate controls
const me = this.soldiers[this.controlledId]
if (me && !me.alive && this.phase === 'live') {
if (e.code === 'ArrowLeft') this.cycleSpectate(-1)
if (e.code === 'ArrowRight') this.cycleSpectate(1)
if (e.code === 'KeyE' || e.code === 'Enter') this.takeOver()
}
}
private onKeyUp = (e: KeyboardEvent) => {
this.keys.delete(e.code)
}
private onMouseMove = (e: MouseEvent) => {
if (!this.pointerLocked) return
const s = this.controlled
if (!s || !s.alive || !s.isPlayer) return
const sens = 0.0022 * (s.scoped ? 0.35 : 1)
s.yaw -= e.movementX * sens
s.pitch = THREE.MathUtils.clamp(s.pitch - e.movementY * sens, -1.55, 1.55)
}
private onMouseDown = (e: MouseEvent) => {
if (this.phase === 'menu' || this.phase === 'matchover') return
if (!this.pointerLocked) return
const me = this.soldiers[this.controlledId]
if (me && !me.alive && this.phase === 'live') {
if (e.button === 0) this.cycleSpectate(1)
return
}
if (e.button === 0) { this.mouseFire = true; this.fireTapQueued = true }
if (e.button === 2) this.altTapQueued = true
}
private onMouseUp = (e: MouseEvent) => {
if (e.button === 0) this.mouseFire = false
}
private onLockChange = () => {
this.pointerLocked = document.pointerLockElement === this.renderer.domElement
if (!this.pointerLocked) this.mouseFire = false
this.syncHud()
}
private onContext = (e: Event) => e.preventDefault()
private onResize = () => this.resize()
private bindEvents() {
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('keyup', this.onKeyUp)
window.addEventListener('mousemove', this.onMouseMove)
window.addEventListener('mousedown', this.onMouseDown)
window.addEventListener('mouseup', this.onMouseUp)
window.addEventListener('resize', this.onResize)
document.addEventListener('pointerlockchange', this.onLockChange)
this.renderer.domElement.addEventListener('contextmenu', this.onContext)
}
private unbindEvents() {
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('keyup', this.onKeyUp)
window.removeEventListener('mousemove', this.onMouseMove)
window.removeEventListener('mousedown', this.onMouseDown)
window.removeEventListener('mouseup', this.onMouseUp)
window.removeEventListener('resize', this.onResize)
document.removeEventListener('pointerlockchange', this.onLockChange)
this.renderer.domElement.removeEventListener('contextmenu', this.onContext)
}
private resize() {
const w = this.container.clientWidth || window.innerWidth
const h = this.container.clientHeight || window.innerHeight
this.renderer.setSize(w, h)
this.camera.aspect = w / h
this.camera.updateProjectionMatrix()
}
get controlled(): Soldier | null {
return this.soldiers[this.controlledId] ?? null
}
get viewed(): Soldier | null {
return this.soldiers[this.viewedId] ?? null
}
private cycleSpectate(dir: number) {
const mates = this.soldiers.filter(s => s.team === this.playerTeam && s.alive)
if (mates.length === 0) return
const curIdx = mates.findIndex(s => s.id === this.viewedId)
const next = mates[(curIdx + dir + mates.length) % mates.length]
this.viewedId = next.id
this.viewmodel.setWeapon(next.weapon.def.id, false)
this.syncHud()
}
private takeOver() {
let target = this.soldiers[this.viewedId]
// still on the death cam (or viewing a corpse): jump to a live teammate first
if (!target || !target.alive || target.id === this.controlledId) {
this.cycleSpectate(1)
target = this.soldiers[this.viewedId]
}
if (!target || !target.alive || target.team !== this.playerTeam || target.isPlayer) return
target.brain = null
target.isPlayer = true
target.name = `你(${target.originalName})`
this.controlledId = target.id
this.viewmodel.setWeapon(target.weapon.def.id, false)
this.syncHud()
}
// ----------------------------------------------------------------- updates
private update(dt: number) {
this.time += dt
if (this.phase === 'menu') {
const t = this.time * 0.06
this.camera.position.set(Math.cos(t) * 75, 52, Math.sin(t) * 75)
this.camera.lookAt(0, 0, 0)
this.viewmodel.group.visible = false
this.effects.update(dt)
this.renderer.render(this.scene, this.camera)
if (this.time > this.hudSyncAt) { this.hudSyncAt = this.time + 0.25; this.syncHud() }
return
}
// phase transitions
if (this.phase === 'freeze' && this.time >= this.phaseEndsAt) this.goLive()
if (this.phase === 'over' && this.time >= this.phaseEndsAt) {
if (this.ctScore >= WIN_SCORE || this.tScore >= WIN_SCORE) {
this.phase = 'matchover'
this.matchWinner = this.ctScore >= WIN_SCORE ? 'CT' : 'T'
document.exitPointerLock?.()
} else {
this.startRound(this.round + 1)
}
this.syncHud()
}
if (this.phase === 'live' && !this.bomb.plantedPos && this.time >= this.roundEndsAt) {
this.winRound('CT', '时间耗尽')
}
const canAct = this.phase === 'live'
// player input → control
const me = this.controlled
if (me && me.isPlayer && me.alive) {
const c = me.control
c.moveX = (this.keys.has('KeyD') ? 1 : 0) - (this.keys.has('KeyA') ? 1 : 0)
c.moveZ = (this.keys.has('KeyW') ? 1 : 0) - (this.keys.has('KeyS') ? 1 : 0)
c.walk = this.keys.has('ShiftLeft') || this.keys.has('ShiftRight')
// edge-triggered inputs stay latched until a fixed step consumes them —
// on high-refresh displays a frame may run zero fixed steps, and a plain
// per-frame assignment would silently eat clicks
c.jump = c.jump || this.jumpQueued
c.fire = this.mouseFire && this.pointerLocked
c.fireTap = c.fireTap || this.fireTapQueued
c.altTap = c.altTap || this.altTapQueued
c.reload = this.keys.has('KeyR')
c.use = this.keys.has('KeyE')
c.slot = this.slotQueued ?? c.slot
if (!canAct) {
c.moveX = 0; c.moveZ = 0; c.jump = false
c.fire = false; c.fireTap = false; c.use = false
}
}
this.jumpQueued = false
this.fireTapQueued = false
this.altTapQueued = false
this.slotQueued = null
// AI
if (canAct) {
const aiWorld: AIWorld = {
map: this.map, nav: this.nav, physics: this.physics,
soldiers: this.soldiers, time: this.time,
bombPlantedPos: this.bomb.plantedPos,
bombDroppedPos: this.bomb.droppedPos,
}
for (const s of this.soldiers) s.brain?.update(dt, aiWorld)
} else {
for (const s of this.soldiers) {
if (s.brain) { s.control.moveX = 0; s.control.moveZ = 0; s.control.fire = false; s.control.fireTap = false; s.control.use = false }
}
}
// fixed-step simulation
this.accumulator = Math.min(this.accumulator + dt, 0.1)
while (this.accumulator >= FIXED_DT) {
this.accumulator -= FIXED_DT
this.fixedStep(FIXED_DT)
}
// doors
const alivePos = this.soldiers.filter(s => s.alive).map(s => s.pos)
for (const d of this.map.doors) {
const ev = d.update(dt, alivePos)
if (ev) this.audio.doorSlide(d.center)
}
// bomb & round timing
this.updateBomb()
if (canAct) this.updateSpectate()
// team vision stamps (minimap)
if (this.time >= this.seenScanAt) {
this.seenScanAt = this.time + 0.15
this.scanVision()
}
// visuals
for (const s of this.soldiers) {
const speed = Math.hypot(s.vel.x, s.vel.z)
s.rig.update(dt, s.pos, s.yaw, s.pitch, speed, s.onGround)
s.rig.root.visible = s.id !== this.viewedId && s.rig.root.visible
if (s.id !== this.viewedId && s.alive) s.rig.root.visible = true
if (!s.alive) { /* keep rig's own fade logic */ }
}
const v = this.viewed
if (v) {
// camera at viewed soldier's eye
const shake = this.effects.shake
this.camera.position.copy(v.eyePos)
this.camera.rotation.y = v.yaw + v.recoilYaw + (Math.random() - 0.5) * shake * 0.05
this.camera.rotation.x = THREE.MathUtils.clamp(v.pitch + v.recoilPitch, -1.55, 1.55) + (Math.random() - 0.5) * shake * 0.05
this.camera.rotation.z = (Math.random() - 0.5) * shake * 0.03
if (!v.alive) {
this.camera.position.y = Math.max(0.4, this.camera.position.y - 1.1)
this.camera.rotation.z += 0.35
}
const targetFov = v.scoped && v.weapon.def.scopeFov ? v.weapon.def.scopeFov : 75
if (Math.abs(this.camera.fov - targetFov) > 0.5) {
this.camera.fov += (targetFov - this.camera.fov) * Math.min(1, 18 * dt)
this.camera.updateProjectionMatrix()
}
const speed = Math.hypot(v.vel.x, v.vel.z)
this.viewmodel.update(dt, speed, v.onGround, v.scoped || !v.alive)
this.audio.setListener(v.eyePos, v.rightDir())
}
this.effects.update(dt)
this.renderer.render(this.scene, this.camera)
if (this.time >= this.hudSyncAt) {
this.hudSyncAt = this.time + 0.05
this.syncHud()
}
}
private fixedStep(dt: number) {
for (const s of this.soldiers) {
if (!s.alive) continue
const c = s.control
// weapon switching
if (c.slot) {
if (s.trySwitch(c.slot)) {
if (s.id === this.viewedId) { this.viewmodel.setWeapon(s.weapon.def.id); this.audio.drawWeapon() }
}
c.slot = null
}
// weapon state
const wpn = s.weapon
wpn.update(dt)
if (c.reload && wpn.startReload()) {
s.scoped = false
this.audio.reload(s.id === this.viewedId ? null : s.pos, wpn.def.reloadTime)
if (s.id === this.viewedId) this.viewmodel.onReload(wpn.def.reloadTime)
}
// scope toggle
if (c.altTap && wpn.def.scopeFov && !wpn.reloading) {
s.scoped = !s.scoped
if (s.id === this.viewedId) this.audio.scope(s.scoped)
}
if (!wpn.def.scopeFov) s.scoped = false
// channels (plant / defuse)
this.updateChannel(s, dt)
// movement (channeling locks you in place)
const chanLock = s.channel !== null
let speed = RUN_SPEED * wpn.def.moveMult
if (c.walk) speed *= 0.52
if (s.scoped) speed *= 0.55
const fwd = new THREE.Vector3(-Math.sin(s.yaw), 0, -Math.cos(s.yaw))
const right = s.rightDir()
const wish = new THREE.Vector3()
if (!chanLock) {
wish.addScaledVector(fwd, c.moveZ).addScaledVector(right, c.moveX)
if (wish.lengthSq() > 1) wish.normalize()
}
this.physics.moveBody(s, wish, speed, c.jump && !chanLock, dt)
c.jump = false
// recoil recovery
s.recoilPitch *= Math.exp(-6 * dt)
s.recoilYaw *= Math.exp(-6 * dt)
// footsteps
const hSpeed = Math.hypot(s.vel.x, s.vel.z)
if (s.onGround && hSpeed > 1) {
s.stepAcc += hSpeed * dt
if (s.stepAcc > 2.55) {
s.stepAcc = 0
const vol = c.walk ? 0.25 : 1
this.audio.footstep(s.id === this.viewedId ? null : s.pos, s.id === this.viewedId ? vol * 0.4 : vol)
}
}
// firing (taps are buffered so clicks during weapon cooldown aren't dropped)
if (c.fireTap) s.tapBuffer = 0.22
if (!chanLock && this.phase === 'live') {
const wantsFire = wpn.def.auto ? (c.fire || s.tapBuffer > 0) : s.tapBuffer > 0
if (wantsFire && wpn.canFire() && s.switchCooldown <= 0) {
this.performShot(s)
s.tapBuffer = 0
}
}
if (s.tapBuffer > 0) s.tapBuffer -= dt
c.fireTap = false
c.altTap = false
if (s.switchCooldown > 0) s.switchCooldown -= dt
// bomb pickup
if (s.team === 'T' && this.bomb.droppedPos && s.alive) {
if (s.pos.distanceTo(this.bomb.droppedPos) < 1.3) {
this.bomb.droppedPos = null
this.bomb.carrierId = s.id
s.hasBomb = true
s.rig.setBomb(true)
this.bombMesh.visible = false
this.reassignPlantDuty(s)
}
}
}
// soldier-vs-soldier separation
const alive = this.soldiers.filter(s => s.alive)
for (let i = 0; i < alive.length; i++) {
for (let j = i + 1; j < alive.length; j++) {
const a = alive[i], b = alive[j]
const dx = b.pos.x - a.pos.x, dz = b.pos.z - a.pos.z
const d2 = dx * dx + dz * dz
if (d2 > 0.0001 && d2 < 0.72 * 0.72) {
const d = Math.sqrt(d2)
const push = (0.72 - d) / 2
const nx = dx / d, nz = dz / d
if (this.physics.circleFree(a.pos.x - nx * push, a.pos.z - nz * push, a.radius, a.pos.y)) {
a.pos.x -= nx * push; a.pos.z -= nz * push
}
if (this.physics.circleFree(b.pos.x + nx * push, b.pos.z + nz * push, b.radius, b.pos.y)) {
b.pos.x += nx * push; b.pos.z += nz * push
}
}
}
}
}
private updateChannel(s: Soldier, dt: number) {
const wantPlant = s.control.use && s.team === 'T' && s.hasBomb && !this.bomb.plantedPos
&& s.onGround && this.map.plantSiteAt(s.pos.x, s.pos.z) !== null && this.phase === 'live'
const wantDefuse = s.control.use && s.team === 'CT' && this.bomb.plantedPos !== null && this.phase === 'live'
&& Math.hypot(this.bomb.plantedPos.x - s.pos.x, this.bomb.plantedPos.z - s.pos.z) < 1.5
if (wantPlant) {
if (!s.channel || s.channel.kind !== 'plant') s.channel = { kind: 'plant', t: 0, total: PLANT_TIME }
s.channel.t += dt
if (Math.floor(s.channel.t * 4) !== Math.floor((s.channel.t - dt) * 4)) this.audio.plantTick(s.id === this.viewedId ? null : s.pos)
if (s.channel.t >= s.channel.total) {
s.channel = null
this.plantBomb(s)
}
} else if (wantDefuse) {
if (!s.channel || s.channel.kind !== 'defuse') s.channel = { kind: 'defuse', t: 0, total: DEFUSE_TIME }
s.channel.t += dt
if (this.time >= this.nextDefTickAt) {
this.nextDefTickAt = this.time + 0.5
this.audio.defuseTick(s.id === this.viewedId ? null : s.pos)
}
if (s.channel.t >= s.channel.total) {
s.channel = null
this.audio.bombDefused()
this.winRound('CT', '炸弹已拆除')
}
} else {
s.channel = null
}
}
// ------------------------------------------------------------------ combat
private performShot(shooter: Soldier) {
const wpn = shooter.weapon
const def = wpn.def
if (def.id === 'knife') {
wpn.onFire()
const isViewed = shooter.id === this.viewedId
this.audio.shot('knife', isViewed ? null : shooter.pos)
if (isViewed) this.viewmodel.onFire(0)
const dir = shooter.viewDir()
const hit = this.physics.raycast(shooter.eyePos, dir, def.range ?? 2.2, this.soldiers, shooter.id, shooter.team)
if (hit?.kind === 'soldier' && hit.soldier) {
this.audio.knifeHit(isViewed ? null : hit.point)
this.applyDamage(hit.soldier as Soldier, def.damage, shooter, hit.part ?? 'chest', def)
this.effects.bloodBurst(hit.point)
} else if (hit) {
// scraped a wall / crate
this.audio.knifeHit(isViewed ? null : hit.point)
this.effects.impact(hit.point)
}
return
}
wpn.onFire()
// spread
let spread = shooter.scoped && def.spreadScoped !== undefined ? def.spreadScoped : def.spreadBase
spread += wpn.bloom - def.spreadPerShot // bloom before this shot's own bump
const hSpeed = Math.hypot(shooter.vel.x, shooter.vel.z)
spread += (hSpeed / RUN_SPEED) * def.spreadMove
if (!shooter.onGround) spread += 0.05
const dir = shooter.viewDir()
const right = shooter.rightDir()
const up = new THREE.Vector3().crossVectors(right, dir).normalize()
const a = Math.random() * Math.PI * 2
const r = Math.sqrt(Math.random()) * spread
dir.addScaledVector(right, Math.cos(a) * r).addScaledVector(up, Math.sin(a) * r).normalize()
// recoil
shooter.recoilPitch += def.kickPitch * (0.85 + Math.random() * 0.3)
shooter.recoilYaw += (Math.random() - 0.5) * def.kickYaw * 2
const isViewed = shooter.id === this.viewedId
this.audio.shot(def.sound, isViewed ? null : shooter.pos)
this.notifyHeard(shooter)
// muzzle position for tracer
const eye = shooter.eyePos
const muzzle = eye.clone().addScaledVector(dir, 0.7).addScaledVector(right, isViewed ? 0.13 : 0.2).addScaledVector(up, -0.1)
this.effects.muzzle(muzzle)
if (isViewed) {
this.viewmodel.onFire(def.kickPitch)
}
const hit = this.physics.raycast(eye, dir, 300, this.soldiers, shooter.id, shooter.team)
const end = hit ? hit.point : eye.clone().addScaledVector(dir, 300)
if (def.id !== 'usp') this.effects.tracer(muzzle, end)
if (hit) {
if (hit.kind === 'soldier' && hit.soldier) {
this.effects.bloodBurst(hit.point)
this.applyDamage(hit.soldier as Soldier, def.damage, shooter, hit.part ?? 'chest', def)
} else {
this.effects.impact(hit.point)
}
}
}
private notifyHeard(shooter: Soldier) {
for (const s of this.soldiers) {
if (!s.alive || s.team === shooter.team || !s.brain) continue
if (s.pos.distanceTo(shooter.pos) < 38) s.brain.heard(shooter.pos, this.time)
}
}
private applyDamage(target: Soldier, baseDmg: number, attacker: Soldier, part: BodyPart, def: { armorPen: number; name: string }) {
if (!target.alive) return
let dmg = baseDmg * PART_MULT[part]
if (target.armor > 0 && part !== 'leg') {
const kept = dmg * def.armorPen
const absorbed = (dmg - kept) * 0.5
target.armor = Math.max(0, target.armor - absorbed)
dmg = kept
}
dmg = Math.round(dmg)
target.hp -= dmg
const headshot = part === 'head'
if (attacker.id === this.controlledId) {
this.hud.state.hitmarkerAt = this.time
this.hud.state.hitmarkerHead = headshot
this.audio.hit(headshot)
}
if (target.id === this.controlledId) {
this.hud.state.damageAt = this.time
this.audio.damage()
}
if (target.hp <= 0) {
this.kill(target, attacker, def.name, headshot)
}
}
private kill(victim: Soldier, attacker: Soldier, weaponName: string, headshot: boolean) {
victim.alive = false
victim.hp = 0
victim.deaths++
attacker.kills++
victim.channel = null
victim.scoped = false
victim.rig.die()
this.audio.death(victim.pos)
if (attacker.id === this.controlledId) this.audio.kill()
// drop bomb
if (victim.hasBomb) {
victim.hasBomb = false
victim.rig.setBomb(false)
this.bomb.carrierId = null
this.bomb.droppedPos = victim.pos.clone()
this.bombMesh.visible = true
this.bombMesh.position.copy(victim.pos)
this.bombMesh.rotation.set(0, Math.random() * 6, 0)
// send nearest T to pick it up
const ts = this.soldiers.filter(s => s.team === 'T' && s.alive && s.brain)
ts.sort((a, b) => a.pos.distanceTo(victim.pos) - b.pos.distanceTo(victim.pos))
if (ts[0]) ts[0].brain!.setMission([], victim.pos.clone(), null, 'pickup')
}
this.hud.state.killfeed.push({
id: this.killfeedId++,
attacker: attacker.name, attackerTeam: attacker.team,
victim: victim.name, victimTeam: victim.team,
weapon: weaponName, headshot, t: this.time,
})
if (this.hud.state.killfeed.length > 6) this.hud.state.killfeed.shift()
if (this.autoMode) console.log(`[kill] ${attacker.name} → ${victim.name} (${weaponName}${headshot ? ', HS' : ''})`)
// a teammate of the victim may investigate
const mates = this.soldiers.filter(s => s.team === victim.team && s.alive && s.brain && !s.brain.target)
if (mates.length && Math.random() < 0.5) {
const m = mates[Math.floor(Math.random() * mates.length)]
m.brain!.heard(victim.pos, this.time)
}
if (victim.id === this.controlledId) this.deathAt = this.time
this.checkElimination()
this.syncHud()
}
// -------------------------------------------------------------------- bomb
private plantBomb(planter: Soldier) {
planter.hasBomb = false
planter.rig.setBomb(false)
this.bomb.carrierId = null
this.bomb.plantedPos = planter.pos.clone()
this.bomb.plantedAt = this.time
this.bombMesh.visible = true
this.bombMesh.position.copy(this.bomb.plantedPos)
this.bombMesh.rotation.set(0, Math.random() * 6, 0)
this.nextBeepAt = this.time
this.audio.bombPlanted()
if (this.autoMode) console.log(`[bomb] planted at ${this.map.plantSiteAt(this.bomb.plantedPos.x, this.bomb.plantedPos.z)}`)
// T defend spots, CT retake + defuse duty
for (const s of this.soldiers) {
if (!s.alive || !s.brain) continue
if (s.team === 'T') {
s.brain.setMission([], this.map.randomSpotNear(this.bomb.plantedPos, 9), this.bomb.plantedPos, 'none')
}
}
this.assignDefuser()
this.syncHud()
}
private assignDefuser() {
if (!this.bomb.plantedPos) return
const cts = this.soldiers.filter(s => s.team === 'CT' && s.alive && s.brain)
cts.sort((a, b) => a.pos.distanceTo(this.bomb.plantedPos!) - b.pos.distanceTo(this.bomb.plantedPos!))
cts.forEach((s, i) => {
if (i === 0) s.brain!.setMission([], this.bomb.plantedPos!.clone(), null, 'defuse')
else s.brain!.setMission([], this.map.randomSpotNear(this.bomb.plantedPos!, 8), this.bomb.plantedPos, 'none')
})
}
private reassignPlantDuty(carrier: Soldier) {
if (!carrier.brain) return
const site: 'A' | 'B' = Math.random() < 0.5 ? 'A' : 'B'
const near = this.map.plantSiteAt(carrier.pos.x, carrier.pos.z)
const chosen = near ?? site
carrier.brain.setMission([], this.map.randomSiteSpot(chosen), null, 'plant')
}
private updateBomb() {
if (this.bomb.plantedPos && !this.bomb.exploded) {
const led = this.bombMesh.getObjectByName('led') as THREE.Mesh | undefined
const remaining = this.bomb.plantedAt + BOMB_FUSE - this.time
if (led) {
const blink = Math.sin(this.time * (remaining < 8 ? 30 : 8)) > 0
;(led.material as THREE.MeshBasicMaterial).color.setHex(blink ? 0xff2222 : 0x441111)
}
if (this.time >= this.nextBeepAt && this.phase === 'live') {
const frac = Math.max(0, remaining / BOMB_FUSE)
this.nextBeepAt = this.time + 0.12 + 0.9 * Math.pow(frac, 1.4)
this.audio.bombBeep(this.bomb.plantedPos, remaining < 10)
}
if (remaining <= 0 && this.phase === 'live') {
this.explodeBomb()
}
// keep a defuser assigned if the previous one died
if (this.phase === 'live' && !this.soldiers.some(s => s.team === 'CT' && s.alive && s.brain?.mission?.duty === 'defuse')) {
this.assignDefuser()
}
}
}
private explodeBomb() {
if (!this.bomb.plantedPos) return
this.bomb.exploded = true
this.bombMesh.visible = false
this.effects.explosion(this.bomb.plantedPos)
this.audio.explosion(this.bomb.plantedPos)
for (const s of this.soldiers) {
if (!s.alive) continue
const d = s.pos.distanceTo(this.bomb.plantedPos)
if (d < 20) {
let dmg = 550 * (1 - d / 20)
if (!this.physics.hasLOS(this.bomb.plantedPos.clone().add(new THREE.Vector3(0, 1, 0)), s.eyePos)) dmg *= 0.4
s.hp -= dmg
if (s.hp <= 0) {
s.alive = false
s.deaths++
s.rig.die()
if (s.id === this.controlledId) this.deathAt = this.time
}
}
}
this.winRound('T', 'C4 爆炸')
}
// ---------------------------------------------------------------- spectate
private updateSpectate() {
const me = this.soldiers[this.controlledId]
if (!me || me.alive) return
if (this.viewedId === this.controlledId && this.time - this.deathAt > 0.8) {
// auto-switch to an alive teammate
const mate = this.soldiers.find(s => s.team === this.playerTeam && s.alive)
if (mate) {
this.viewedId = mate.id
this.viewmodel.setWeapon(mate.weapon.def.id, false)
}
}
const v = this.viewed
if (v && !v.alive && v.id !== this.controlledId) {
const mate = this.soldiers.find(s => s.team === this.playerTeam && s.alive)
if (mate) {
this.viewedId = mate.id
this.viewmodel.setWeapon(mate.weapon.def.id, false)
} else {
this.viewedId = this.controlledId
}
}
}
private scanVision() {
for (const team of ['CT', 'T'] as Team[]) {
const observers = this.soldiers.filter(s => s.team === team && s.alive)
for (const enemy of this.soldiers) {
if (enemy.team === team || !enemy.alive) continue
for (const o of observers) {
const dx = enemy.pos.x - o.pos.x, dz = enemy.pos.z - o.pos.z
const dist = Math.hypot(dx, dz)
if (dist > 70) continue
const dirYaw = Math.atan2(-dx, -dz)
let dyaw = (dirYaw - o.yaw) % (Math.PI * 2)
if (dyaw > Math.PI) dyaw -= Math.PI * 2
if (dyaw < -Math.PI) dyaw += Math.PI * 2
if (Math.abs(dyaw) > 1.1 && dist > 3) continue
if (!this.physics.hasLOS(o.eyePos, new THREE.Vector3(enemy.pos.x, enemy.pos.y + 1.3, enemy.pos.z))) continue
enemy.lastSeenBy[team] = this.time
break
}
}
}
}
// ------------------------------------------------------------------- hud
private syncHud() {
const st = this.hud.state
st.phase = this.phase
st.locked = this.pointerLocked
st.auto = this.autoMode
st.round = this.round
st.pistolRound = this.round === 1
st.ctScore = this.ctScore
st.tScore = this.tScore
st.playerTeam = this.playerTeam
st.loadout = this.playerLoadout
st.winner = this.winner
st.winReason = this.winReason
st.matchWinner = this.matchWinner
st.freezeLeft = this.phase === 'freeze' ? Math.max(0, this.phaseEndsAt - this.time) : 0
st.timeLeft = this.phase === 'live' ? Math.max(0, this.roundEndsAt - this.time) : ROUND_TIME
st.bombPlanted = this.bomb.plantedPos !== null && !this.bomb.exploded
st.bombTimeLeft = st.bombPlanted ? Math.max(0, this.bomb.plantedAt + BOMB_FUSE - this.time) : null
const me = this.soldiers[this.controlledId]
const v = this.viewed ?? me
if (v) {
const wpn = v.weapon
st.me = {
name: v.name, team: v.team, hp: Math.max(0, Math.ceil(v.hp)), armor: Math.ceil(v.armor),
alive: v.alive, weaponName: wpn.def.name, weaponId: wpn.def.id,
mag: wpn.mag, reserve: wpn.reserve, reloading: wpn.reloading,
scoped: v.scoped, hasBomb: v.hasBomb,
spreadPx: 5 + (wpn.bloom + wpn.def.spreadBase) * 900 + Math.hypot(v.vel.x, v.vel.z) * 1.4,
}
st.channel = v.channel ? {
label: v.channel.kind === 'plant' ? '正在安放 C4…' : '正在拆除 C4…',
progress: v.channel.t / v.channel.total,
} : null
// center hint
st.centerHint = null
if (v.alive && this.phase === 'live' && !v.channel) {
if (v.team === 'T' && v.hasBomb && this.map.plantSiteAt(v.pos.x, v.pos.z)) st.centerHint = '按住 E 安放 C4'
else if (v.team === 'CT' && this.bomb.plantedPos && Math.hypot(this.bomb.plantedPos.x - v.pos.x, this.bomb.plantedPos.z - v.pos.z) < 3) st.centerHint = '按住 E 拆除 C4'
}
}
st.spectating = me && v && v.id !== me.id
? { name: v.name, isSelf: false }
: (me && !me.alive ? { name: me.name, isSelf: true } : null)
// prune old killfeed
st.killfeed = st.killfeed.filter(k => this.time - k.t < 6.5)
this.hud.bump()
}
// Minimap data access (imperative canvas drawing from React)
minimapInfo() {
return {
map: this.map,
soldiers: this.soldiers,
viewedId: this.viewedId,
playerTeam: this.playerTeam,
time: this.time,
bomb: this.bomb,
}
}
}