feat: add cs-dust2 React case with two model takes + react kind support
New case cs-dust2 (kind:react): a from-scratch React + three.js 5v5 Dust2 FPS. Two takes registered: Claude Fable 5 / Claude Code / High (with a hand fix for a 64Hz fixed-step firing bug on high-refresh displays) and Claude Opus 4.8 / Claude Code / Max (dust2-arena, one-shot). React cases commit source only; the single file is built from source at deploy time. validate-data.ts now accepts a React project directory as the declared artifact. AGENTS.md rewritten English-only with React-case, bare-harness (no skills/plugins) and artifact-anonymity rules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c4cc293748
commit
cbfce3b7cc
62 files changed
+13583
-43
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Engine } from './game/engine'
|
||||
import HUD from './ui/HUD'
|
||||
|
||||
export default function App() {
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const [engine, setEngine] = useState<Engine | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const e = new Engine(viewportRef.current!)
|
||||
setEngine(e)
|
||||
;(window as unknown as { __engine?: Engine }).__engine = e
|
||||
if (new URLSearchParams(location.search).has('auto')) e.start('CT', true)
|
||||
return () => e.dispose()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div ref={viewportRef} className="viewport" />
|
||||
{engine && <HUD engine={engine} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './ui/hud.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from 'react'
|
||||
import type { Engine } from '../game/engine'
|
||||
import { GRID } from '../game/map'
|
||||
import type { HudState, Team } from '../game/types'
|
||||
|
||||
const MINIMAP_PX = 4 // px per cell
|
||||
|
||||
function fmtTime(t: number): string {
|
||||
const s = Math.max(0, Math.ceil(t))
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function Minimap({ engine }: { engine: Engine }) {
|
||||
const bgRef = useRef<HTMLCanvasElement>(null)
|
||||
const fgRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const bg = bgRef.current!
|
||||
engine.map.drawMinimap(bg.getContext('2d')!, MINIMAP_PX)
|
||||
|
||||
let raf = 0
|
||||
const draw = () => {
|
||||
raf = requestAnimationFrame(draw)
|
||||
const fg = fgRef.current
|
||||
if (!fg) return
|
||||
const ctx = fg.getContext('2d')!
|
||||
const size = GRID * MINIMAP_PX
|
||||
ctx.clearRect(0, 0, size, size)
|
||||
const info = engine.minimapInfo()
|
||||
const toPx = (x: number, z: number): [number, number] => [
|
||||
(x / 2.5 + GRID / 2) * MINIMAP_PX,
|
||||
(z / 2.5 + GRID / 2) * MINIMAP_PX,
|
||||
]
|
||||
|
||||
// bomb
|
||||
const bomb = info.bomb
|
||||
let bombPos = bomb.plantedPos ?? bomb.droppedPos
|
||||
if (!bombPos && bomb.carrierId !== null) {
|
||||
const carrier = info.soldiers[bomb.carrierId]
|
||||
if (carrier && (carrier.team === info.playerTeam || carrier.lastSeenBy[info.playerTeam] > info.time - 2)) {
|
||||
bombPos = carrier.pos
|
||||
}
|
||||
}
|
||||
if (bombPos && (bomb.plantedPos ? Math.sin(info.time * 10) > -0.4 : true)) {
|
||||
const [bx, bz] = toPx(bombPos.x, bombPos.z)
|
||||
ctx.fillStyle = '#ff7828'
|
||||
ctx.fillRect(bx - 4, bz - 4, 8, 8)
|
||||
ctx.fillStyle = '#000'
|
||||
ctx.font = 'bold 7px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('C4', bx, bz + 2.5)
|
||||
}
|
||||
|
||||
for (const s of info.soldiers) {
|
||||
if (!s.alive) continue
|
||||
const mine = s.team === info.playerTeam
|
||||
const seen = s.lastSeenBy[info.playerTeam] > info.time - 1.6
|
||||
if (!mine && !seen) continue
|
||||
const [px, pz] = toPx(s.pos.x, s.pos.z)
|
||||
if (s.id === info.viewedId) {
|
||||
// white arrow with view direction
|
||||
ctx.save()
|
||||
ctx.translate(px, pz)
|
||||
ctx.rotate(-s.yaw)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, -6)
|
||||
ctx.lineTo(4.4, 4.4)
|
||||
ctx.lineTo(0, 2)
|
||||
ctx.lineTo(-4.4, 4.4)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
} else {
|
||||
ctx.fillStyle = mine ? '#57c46a' : '#e84c3d'
|
||||
ctx.beginPath()
|
||||
ctx.arc(px, pz, 3.4, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(draw)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [engine])
|
||||
|
||||
const size = GRID * MINIMAP_PX
|
||||
return (
|
||||
<div className="minimap">
|
||||
<canvas ref={bgRef} width={size} height={size} />
|
||||
<canvas ref={fgRef} width={size} height={size} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Crosshair({ st }: { st: HudState }) {
|
||||
if (st.me.scoped) return null
|
||||
if (!st.me.alive) return null
|
||||
if (st.me.weaponId === 'awp') {
|
||||
// unscoped AWP: big circle = inaccurate
|
||||
return <div className="crosshair-circle" />
|
||||
}
|
||||
const gap = Math.min(40, st.me.spreadPx)
|
||||
const len = 9
|
||||
return (
|
||||
<div className="crosshair">
|
||||
<div className="ch-dot" />
|
||||
<div className="ch-line" style={{ left: '50%', top: '50%', width: 2, height: len, transform: `translate(-50%, ${gap}px)` }} />
|
||||
<div className="ch-line" style={{ left: '50%', top: '50%', width: 2, height: len, transform: `translate(-50%, ${-gap - len}px)` }} />
|
||||
<div className="ch-line" style={{ left: '50%', top: '50%', width: len, height: 2, transform: `translate(${gap}px, -50%)` }} />
|
||||
<div className="ch-line" style={{ left: '50%', top: '50%', width: len, height: 2, transform: `translate(${-gap - len}px, -50%)` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Scope() {
|
||||
return (
|
||||
<div className="scope">
|
||||
<div className="scope-cutout" />
|
||||
<div className="scope-hline" />
|
||||
<div className="scope-vline" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TeamTag({ team }: { team: Team }) {
|
||||
return <span className={`teamtag ${team === 'CT' ? 'ct' : 't'}`}>{team}</span>
|
||||
}
|
||||
|
||||
export default function HUD({ engine }: { engine: Engine }) {
|
||||
useSyncExternalStore(engine.hud.subscribe, engine.hud.getSnapshot)
|
||||
const st = engine.hud.state
|
||||
const now = engine.time
|
||||
|
||||
if (st.phase === 'menu') {
|
||||
return (
|
||||
<div className="overlay menu">
|
||||
<h1>DUST II</h1>
|
||||
<p className="subtitle">5v5 炸弹模式 · 三维第一人称 · 纯代码生成</p>
|
||||
<div className="team-select">
|
||||
<button className="team-card ct" onClick={() => { engine.start('CT'); engine.requestLock() }}>
|
||||
<span className="team-name">加入 CT</span>
|
||||
<span className="team-desc">反恐精英 · USP-S 起手<br />守卫 A / B 点,拆除 C4</span>
|
||||
</button>
|
||||
<button className="team-card t" onClick={() => { engine.start('T'); engine.requestLock() }}>
|
||||
<span className="team-name">加入 T</span>
|
||||
<span className="team-desc">恐怖分子 · Glock 起手<br />突入包点,安放 C4</span>
|
||||
</button>
|
||||
</div>
|
||||
<button className="spectate-btn" onClick={() => engine.start('CT', true)}>AI 对战观赏模式</button>
|
||||
<div className="controls-help">
|
||||
<span>WASD 移动</span><span>空格 跳跃</span><span>Shift 静步</span>
|
||||
<span>鼠标左键 开火</span><span>右键 AWP 开镜</span><span>R 换弹</span>
|
||||
<span>1/2/3 切枪</span><span>E 安放 / 拆除</span><span>B 购买阶段换 AWP</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const dmgFlash = now - st.damageAt < 0.4
|
||||
const hitAge = now - st.hitmarkerAt
|
||||
|
||||
return (
|
||||
<div className="hud">
|
||||
{st.me.scoped && <Scope />}
|
||||
<Crosshair st={st} />
|
||||
|
||||
{hitAge < 0.25 && (
|
||||
<div className={`hitmarker ${st.hitmarkerHead ? 'head' : ''}`}>
|
||||
<span /><span /><span /><span />
|
||||
</div>
|
||||
)}
|
||||
{dmgFlash && <div className="damage-flash" />}
|
||||
|
||||
{/* top bar */}
|
||||
<div className="topbar">
|
||||
<div className="score ct-score">{st.ctScore}</div>
|
||||
<div className="timer">
|
||||
{st.bombPlanted
|
||||
? <span className="bomb-timer">💣 {Math.ceil(st.bombTimeLeft ?? 0)}</span>
|
||||
: <span>{st.phase === 'freeze' ? fmtTime(st.freezeLeft) : fmtTime(st.timeLeft)}</span>}
|
||||
<div className="round-label">回合 {st.round}{st.pistolRound ? ' · 手枪局' : ''}</div>
|
||||
</div>
|
||||
<div className="score t-score">{st.tScore}</div>
|
||||
</div>
|
||||
|
||||
<Minimap engine={engine} />
|
||||
|
||||
{/* killfeed */}
|
||||
<div className="killfeed">
|
||||
{st.killfeed.map(k => (
|
||||
<div className="kf-entry" key={k.id}>
|
||||
<span className={k.attackerTeam === 'CT' ? 'kf-ct' : 'kf-t'}>{k.attacker}</span>
|
||||
<span className="kf-weapon">{k.weapon}{k.headshot ? ' ◎' : ''}</span>
|
||||
<span className={k.victimTeam === 'CT' ? 'kf-ct' : 'kf-t'}>{k.victim}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* bottom left: hp / armor */}
|
||||
{st.me.alive && (
|
||||
<div className="vitals">
|
||||
<div className="vital">
|
||||
<span className="vital-icon">+</span>
|
||||
<span className="vital-num">{st.me.hp}</span>
|
||||
<div className="vital-bar"><div style={{ width: `${st.me.hp}%` }} className="vital-fill hp" /></div>
|
||||
</div>
|
||||
<div className="vital">
|
||||
<span className="vital-icon">🛡</span>
|
||||
<span className="vital-num">{st.me.armor}</span>
|
||||
<div className="vital-bar"><div style={{ width: `${st.me.armor}%` }} className="vital-fill armor" /></div>
|
||||
</div>
|
||||
{st.me.hasBomb && <div className="carrying-bomb">携带 C4</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* bottom right: ammo */}
|
||||
{st.me.alive && (
|
||||
<div className="ammo">
|
||||
<div className="weapon-name">{st.me.weaponName}{st.me.reloading ? '(换弹中…)' : ''}</div>
|
||||
{st.me.weaponId !== 'knife' && (
|
||||
<div className="ammo-nums">
|
||||
<span className="ammo-mag">{st.me.mag}</span>
|
||||
<span className="ammo-sep">/</span>
|
||||
<span className="ammo-reserve">{st.me.reserve}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* center messages */}
|
||||
<div className="center-stack">
|
||||
{st.phase === 'freeze' && (
|
||||
<div className="banner freeze">
|
||||
<div className="banner-title">准备阶段 · {Math.ceil(st.freezeLeft)}s</div>
|
||||
{!st.pistolRound && <div className="banner-sub">按 B 切换主武器:当前 {st.loadout === 'awp' ? 'AWP' : '步枪'}</div>}
|
||||
{st.pistolRound && <div className="banner-sub">手枪局 — {st.playerTeam === 'T' ? 'Glock-18' : 'USP-S'},无护甲</div>}
|
||||
</div>
|
||||
)}
|
||||
{st.channel && (
|
||||
<div className="channel">
|
||||
<div className="channel-label">{st.channel.label}</div>
|
||||
<div className="channel-bar"><div style={{ width: `${st.channel.progress * 100}%` }} /></div>
|
||||
</div>
|
||||
)}
|
||||
{st.centerHint && !st.channel && <div className="hint">{st.centerHint}</div>}
|
||||
{st.bombPlanted && st.phase === 'live' && <div className="planted-note">C4 已安放</div>}
|
||||
{st.phase === 'over' && st.winner && (
|
||||
<div className={`banner winner ${st.winner === 'CT' ? 'ct' : 't'}`}>
|
||||
<div className="banner-title">{st.winner} 获胜</div>
|
||||
<div className="banner-sub">{st.winReason}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* spectate bar */}
|
||||
{st.spectating && st.phase === 'live' && (
|
||||
<div className="spectate-bar">
|
||||
{st.spectating.isSelf
|
||||
? <span>你已阵亡 — 正在寻找存活队友…</span>
|
||||
: <>
|
||||
<span>观战中:<b>{st.spectating.name}</b> <TeamTag team={st.me.team} /></span>
|
||||
<span className="spec-keys">←/→ 或 左键 切换 · E 接管该队友</span>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* pause overlay (pointer unlocked mid-game) */}
|
||||
{!st.locked && !st.auto && st.phase !== 'matchover' && (
|
||||
<div className="overlay pause" onClick={() => engine.requestLock()}>
|
||||
<div className="pause-box">
|
||||
<h2>已暂停 / 鼠标未锁定</h2>
|
||||
<p>点击任意处继续(锁定鼠标)</p>
|
||||
<button onClick={e => { e.stopPropagation(); engine.toMenu() }}>返回主菜单</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{st.phase === 'matchover' && st.matchWinner && (
|
||||
<div className="overlay">
|
||||
<div className="pause-box">
|
||||
<h2>比赛结束 — {st.matchWinner} {st.matchWinner === st.playerTeam ? '胜利 🏆' : '获胜'}</h2>
|
||||
<p>CT {st.ctScore} : {st.tScore} T</p>
|
||||
<button onClick={() => { engine.restartMatch(); engine.requestLock() }}>再来一局</button>
|
||||
<button onClick={() => engine.toMenu()}>返回主菜单</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.app { position: relative; width: 100%; height: 100%; font-family: -apple-system, "PingFang SC", "Segoe UI", Roboto, sans-serif; user-select: none; }
|
||||
.viewport { position: absolute; inset: 0; }
|
||||
.viewport canvas { display: block; }
|
||||
|
||||
.hud { position: absolute; inset: 0; pointer-events: none; color: #eee; }
|
||||
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: auto; }
|
||||
|
||||
/* ---------- menu ---------- */
|
||||
.menu { background: linear-gradient(180deg, rgba(10,14,20,0.55), rgba(10,14,20,0.82)); }
|
||||
.menu h1 { font-size: 84px; margin: 0; letter-spacing: 22px; color: #f2e3c2; text-shadow: 0 4px 24px rgba(0,0,0,0.6); font-weight: 800; }
|
||||
.menu .subtitle { color: #b8c4d0; letter-spacing: 4px; margin: 4px 0 42px; }
|
||||
.team-select { display: flex; gap: 28px; }
|
||||
.team-card { width: 250px; padding: 26px 22px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); cursor: pointer; text-align: left; transition: transform 0.12s, box-shadow 0.12s; color: #fff; }
|
||||
.team-card:hover { transform: translateY(-4px); box-shadow: 0 12px 34px rgba(0,0,0,0.5); }
|
||||
.team-card.ct { background: linear-gradient(160deg, #2c4a66, #1a2c3e); }
|
||||
.team-card.t { background: linear-gradient(160deg, #7a5c30, #4a3820); }
|
||||
.team-name { display: block; font-size: 26px; font-weight: 700; margin-bottom: 10px; }
|
||||
.team-desc { display: block; font-size: 13px; line-height: 1.7; color: rgba(255,255,255,0.75); }
|
||||
.spectate-btn { margin-top: 26px; background: none; border: 1px solid rgba(255,255,255,0.3); color: #b8c4d0; padding: 9px 26px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.spectate-btn:hover { border-color: #fff; color: #fff; }
|
||||
.controls-help { margin-top: 40px; display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; max-width: 640px; color: #8a97a5; font-size: 12.5px; }
|
||||
.controls-help span { background: rgba(255,255,255,0.07); padding: 5px 12px; border-radius: 4px; }
|
||||
|
||||
/* ---------- crosshair ---------- */
|
||||
.crosshair { position: absolute; inset: 0; }
|
||||
.ch-dot { position: absolute; left: 50%; top: 50%; width: 2px; height: 2px; background: #4dff9d; transform: translate(-50%, -50%); box-shadow: 0 0 2px #000; }
|
||||
.ch-line { position: absolute; background: #4dff9d; box-shadow: 0 0 2px rgba(0,0,0,0.9); }
|
||||
.crosshair-circle { position: absolute; left: 50%; top: 50%; width: 120px; height: 120px; border: 1.5px dashed rgba(255,255,255,0.5); border-radius: 50%; transform: translate(-50%, -50%); }
|
||||
|
||||
/* ---------- scope ---------- */
|
||||
.scope { position: absolute; inset: 0; }
|
||||
.scope-cutout { position: absolute; inset: 0; background: radial-gradient(circle at center, transparent 0, transparent 33vmin, rgba(0,0,0,0.985) 34vmin); }
|
||||
.scope-hline { position: absolute; top: 50%; left: 0; right: 0; height: 1.5px; background: rgba(0,0,0,0.9); }
|
||||
.scope-vline { position: absolute; left: 50%; top: 0; bottom: 0; width: 1.5px; background: rgba(0,0,0,0.9); }
|
||||
|
||||
/* ---------- hitmarker / damage ---------- */
|
||||
.hitmarker span { position: absolute; left: 50%; top: 50%; width: 2.5px; height: 10px; background: #fff; }
|
||||
.hitmarker.head span { background: #ff5544; }
|
||||
.hitmarker span:nth-child(1) { transform: translate(-50%,-50%) rotate(45deg) translateY(-11px); }
|
||||
.hitmarker span:nth-child(2) { transform: translate(-50%,-50%) rotate(135deg) translateY(-11px); }
|
||||
.hitmarker span:nth-child(3) { transform: translate(-50%,-50%) rotate(225deg) translateY(-11px); }
|
||||
.hitmarker span:nth-child(4) { transform: translate(-50%,-50%) rotate(315deg) translateY(-11px); }
|
||||
.damage-flash { position: absolute; inset: 0; box-shadow: inset 0 0 140px 40px rgba(200,20,20,0.55); }
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
.topbar { position: absolute; top: 14px; left: 50%; transform: translateX(-50%); display: flex; align-items: stretch; gap: 2px; }
|
||||
.score { min-width: 58px; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; padding: 4px 10px; }
|
||||
.ct-score { background: rgba(58,110,165,0.85); border-radius: 6px 0 0 6px; }
|
||||
.t-score { background: rgba(196,142,54,0.85); border-radius: 0 6px 6px 0; }
|
||||
.timer { background: rgba(12,16,22,0.82); padding: 5px 18px; text-align: center; font-size: 21px; font-weight: 700; min-width: 96px; }
|
||||
.timer .bomb-timer { color: #ff7828; }
|
||||
.round-label { font-size: 10.5px; font-weight: 400; color: #93a1af; margin-top: 1px; }
|
||||
|
||||
/* ---------- minimap ---------- */
|
||||
.minimap { position: absolute; top: 14px; left: 14px; background: rgba(10,13,18,0.72); border: 1px solid rgba(255,255,255,0.14); border-radius: 6px; padding: 4px; }
|
||||
.minimap canvas { display: block; }
|
||||
.minimap canvas + canvas { position: absolute; top: 4px; left: 4px; }
|
||||
|
||||
/* ---------- killfeed ---------- */
|
||||
.killfeed { position: absolute; top: 14px; right: 14px; display: flex; flex-direction: column; gap: 5px; align-items: flex-end; }
|
||||
.kf-entry { background: rgba(10,13,18,0.78); border-radius: 4px; padding: 5px 11px; font-size: 13px; font-weight: 600; display: flex; gap: 8px; }
|
||||
.kf-ct { color: #6fa8dc; }
|
||||
.kf-t { color: #e0aa4e; }
|
||||
.kf-weapon { color: #cfd6dd; font-weight: 400; font-size: 12px; align-self: center; }
|
||||
|
||||
/* ---------- vitals ---------- */
|
||||
.vitals { position: absolute; left: 18px; bottom: 18px; display: flex; gap: 22px; align-items: flex-end; }
|
||||
.vital { background: rgba(10,13,18,0.72); border-radius: 6px; padding: 8px 14px; display: flex; align-items: center; gap: 9px; min-width: 130px; }
|
||||
.vital-icon { font-size: 15px; color: #aab7c4; }
|
||||
.vital-num { font-size: 26px; font-weight: 800; min-width: 44px; }
|
||||
.vital-bar { flex: 1; height: 4px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; min-width: 40px; }
|
||||
.vital-fill { height: 100%; border-radius: 2px; transition: width 0.15s; }
|
||||
.vital-fill.hp { background: #57c46a; }
|
||||
.vital-fill.armor { background: #6fa8dc; }
|
||||
.carrying-bomb { background: rgba(180,80,20,0.85); padding: 8px 14px; border-radius: 6px; font-weight: 700; font-size: 14px; }
|
||||
|
||||
/* ---------- ammo ---------- */
|
||||
.ammo { position: absolute; right: 18px; bottom: 18px; text-align: right; background: rgba(10,13,18,0.72); border-radius: 6px; padding: 10px 16px; }
|
||||
.weapon-name { font-size: 13px; color: #aab7c4; letter-spacing: 1px; margin-bottom: 2px; }
|
||||
.ammo-nums { font-weight: 800; }
|
||||
.ammo-mag { font-size: 34px; }
|
||||
.ammo-sep { font-size: 20px; color: #66727e; margin: 0 6px; }
|
||||
.ammo-reserve { font-size: 20px; color: #93a1af; }
|
||||
|
||||
/* ---------- center stack ---------- */
|
||||
.center-stack { position: absolute; left: 50%; top: 58%; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.banner { text-align: center; background: rgba(10,13,18,0.8); border-radius: 8px; padding: 12px 34px; }
|
||||
.banner-title { font-size: 24px; font-weight: 800; letter-spacing: 2px; }
|
||||
.banner-sub { font-size: 13px; color: #aab7c4; margin-top: 4px; }
|
||||
.banner.winner.ct .banner-title { color: #6fa8dc; }
|
||||
.banner.winner.t .banner-title { color: #e0aa4e; }
|
||||
.channel { width: 300px; background: rgba(10,13,18,0.8); border-radius: 8px; padding: 12px 18px; }
|
||||
.channel-label { font-size: 14px; font-weight: 700; margin-bottom: 8px; text-align: center; }
|
||||
.channel-bar { height: 8px; background: rgba(255,255,255,0.15); border-radius: 4px; overflow: hidden; }
|
||||
.channel-bar div { height: 100%; background: #ff7828; }
|
||||
.hint { background: rgba(10,13,18,0.8); padding: 8px 22px; border-radius: 6px; font-size: 15px; font-weight: 600; color: #ffd28a; }
|
||||
.planted-note { color: #ff7828; font-weight: 700; font-size: 13px; letter-spacing: 2px; text-shadow: 0 1px 4px #000; }
|
||||
|
||||
/* ---------- spectate ---------- */
|
||||
.spectate-bar { position: absolute; left: 50%; bottom: 90px; transform: translateX(-50%); background: rgba(10,13,18,0.85); border: 1px solid rgba(255,255,255,0.16); padding: 12px 26px; border-radius: 8px; display: flex; flex-direction: column; align-items: center; gap: 5px; font-size: 15px; }
|
||||
.spec-keys { font-size: 12px; color: #93a1af; }
|
||||
.teamtag { font-size: 11px; font-weight: 800; padding: 1px 7px; border-radius: 3px; margin-left: 4px; }
|
||||
.teamtag.ct { background: #3a6ea5; }
|
||||
.teamtag.t { background: #c48e36; }
|
||||
|
||||
/* ---------- pause / matchover ---------- */
|
||||
.pause { background: rgba(6,8,11,0.6); cursor: pointer; }
|
||||
.pause-box { background: rgba(14,18,24,0.95); border: 1px solid rgba(255,255,255,0.15); border-radius: 12px; padding: 34px 48px; text-align: center; }
|
||||
.pause-box h2 { margin: 0 0 10px; font-size: 22px; }
|
||||
.pause-box p { color: #93a1af; margin: 0 0 20px; }
|
||||
.pause-box button { display: block; width: 100%; margin-top: 10px; background: rgba(255,255,255,0.1); color: #eee; border: 1px solid rgba(255,255,255,0.25); padding: 10px 0; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.pause-box button:hover { background: rgba(255,255,255,0.2); }
|
||||
Reference in new issue
Block a user