spoint 0.1.645 → 0.1.646
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/tps-game/client-app.js +296 -0
- package/apps/tps-game/index.js +5 -541
- package/apps/tps-game/server-app.js +190 -0
- package/apps/tps-game/shared.js +60 -0
- package/package.json +1 -1
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { EMOTE_WHEEL_SLOTS, predictHit } from './shared.js'
|
|
2
|
+
|
|
3
|
+
// Feature-detected haptic pulse, gated on MobileControls being the active input path (touch device,
|
|
4
|
+
// not just any browser with the Vibration API) so desktop Chrome/Android-tablet-with-keyboard don't
|
|
5
|
+
// buzz on every shot. No-op server-side (engine.mobileControls is undefined there) and on iOS/desktop
|
|
6
|
+
// (no navigator.vibrate).
|
|
7
|
+
function mobileVibrate(engine, pattern) {
|
|
8
|
+
if (!engine?.mobileControls?.enabled) return
|
|
9
|
+
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') return
|
|
10
|
+
navigator.vibrate(pattern)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// caller must de-dupe: called from both the optimistic 'hit' path and the authoritative 'death' path
|
|
14
|
+
function creditKill(tps, authStreak) {
|
|
15
|
+
const now = Date.now()
|
|
16
|
+
tps.killTime = now; tps.kills = (tps.kills || 0) + 1
|
|
17
|
+
if (typeof window !== 'undefined' && window.__funJuice) window.__funJuice.kill++
|
|
18
|
+
tps.streak = (typeof authStreak === 'number' && authStreak > 0) ? authStreak : ((now - (tps.lastKillTime || 0) < 3000) ? (tps.streak || 1) + 1 : 1)
|
|
19
|
+
tps.lastKillTime = now
|
|
20
|
+
tps.juice?.tone(420, 0.28, 0.2, 760 + Math.min(4, tps.streak - 1) * 120)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeJuice() {
|
|
24
|
+
let actx = null
|
|
25
|
+
if (typeof window !== 'undefined' && !window.__funJuice) {
|
|
26
|
+
window.__funJuice = { tones: [], hit: 0, headshot: 0, kill: 0, empty: 0, muted: false }
|
|
27
|
+
}
|
|
28
|
+
const ensure = () => {
|
|
29
|
+
if (typeof window === 'undefined') return null
|
|
30
|
+
if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)() } catch (e) { return null } }
|
|
31
|
+
if (actx.state === 'suspended') { try { actx.resume() } catch (e) {} }
|
|
32
|
+
return actx
|
|
33
|
+
}
|
|
34
|
+
const tone = (freq, dur, vol = 0.18, rampTo = freq) => {
|
|
35
|
+
if (window.__funJuice) { window.__funJuice.tones.push({ freq, dur }); if (window.__funJuice.muted) return }
|
|
36
|
+
const a = ensure(); if (!a) return
|
|
37
|
+
const t0 = a.currentTime
|
|
38
|
+
const osc = a.createOscillator(); const g = a.createGain()
|
|
39
|
+
osc.frequency.setValueAtTime(freq, t0)
|
|
40
|
+
if (rampTo !== freq) osc.frequency.exponentialRampToValueAtTime(Math.max(1, rampTo), t0 + dur)
|
|
41
|
+
g.gain.setValueAtTime(0.0001, t0)
|
|
42
|
+
g.gain.exponentialRampToValueAtTime(Math.min(0.3, vol), t0 + Math.min(0.008, dur * 0.3))
|
|
43
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
|
|
44
|
+
osc.connect(g).connect(a.destination)
|
|
45
|
+
osc.start(t0); osc.stop(t0 + dur + 0.02)
|
|
46
|
+
}
|
|
47
|
+
return { tone }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeOverlay() {
|
|
51
|
+
if (typeof document === 'undefined') return null
|
|
52
|
+
let root = document.getElementById('tps-juice')
|
|
53
|
+
if (root) return root
|
|
54
|
+
root = document.createElement('div')
|
|
55
|
+
root.id = 'tps-juice'
|
|
56
|
+
root.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:50;font-family:system-ui,sans-serif'
|
|
57
|
+
root.innerHTML =
|
|
58
|
+
'<div id="tps-cross" style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) scale(1);transition:transform .08s ease-out;width:18px;height:18px">' +
|
|
59
|
+
'<span style="position:absolute;left:8px;top:0;width:2px;height:18px;background:rgba(255,255,255,.65)"></span>' +
|
|
60
|
+
'<span style="position:absolute;left:0;top:8px;width:18px;height:2px;background:rgba(255,255,255,.65)"></span>' +
|
|
61
|
+
'</div>' +
|
|
62
|
+
'<div id="tps-hit" style="position:absolute;left:50%;top:50%;width:0;height:0;transform:translate(-50%,-50%) rotate(45deg);opacity:0;transition:opacity .05s">' +
|
|
63
|
+
'<span class="hm" style="position:absolute;left:-1px;top:-13px;width:2px;height:8px"></span>' +
|
|
64
|
+
'<span class="hm" style="position:absolute;left:-1px;top:5px;width:2px;height:8px"></span>' +
|
|
65
|
+
'<span class="hm" style="position:absolute;left:-13px;top:-1px;width:8px;height:2px"></span>' +
|
|
66
|
+
'<span class="hm" style="position:absolute;left:5px;top:-1px;width:8px;height:2px"></span>' +
|
|
67
|
+
'</div>' +
|
|
68
|
+
'<div id="tps-kill" style="position:absolute;left:50%;top:36%;transform:translate(-50%,-50%);color:#ffcc33;font-weight:800;font-size:34px;letter-spacing:2px;text-shadow:0 2px 8px #000;opacity:0;transition:opacity .15s"></div>' +
|
|
69
|
+
'<div id="tps-dmg-dealt" style="position:absolute;left:52%;top:47%;color:#ffe27a;font-weight:700;font-size:17px;text-shadow:0 1px 3px #000;opacity:0;transition:opacity .05s"></div>' +
|
|
70
|
+
'<div id="tps-dmg-taken" style="position:absolute;left:50%;top:41%;transform:translate(-50%,-50%);color:#ff7a7a;font-weight:800;font-size:24px;text-shadow:0 2px 6px #000;opacity:0;transition:opacity .1s"></div>' +
|
|
71
|
+
'<div id="tps-shield" style="position:absolute;inset:0;opacity:0;transition:opacity .15s;box-shadow:inset 0 0 90px 18px rgba(80,180,255,0.45)"></div>' +
|
|
72
|
+
'<div id="tps-vignette" style="position:absolute;inset:0;opacity:0;transition:opacity .12s;box-shadow:inset 0 0 140px 40px rgba(200,0,0,0.9);background:radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(160,0,0,0.35) 100%)"></div>'
|
|
73
|
+
document.body.appendChild(root)
|
|
74
|
+
return root
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const tpsGameClient = {
|
|
78
|
+
_tps: null,
|
|
79
|
+
setup(engine) {
|
|
80
|
+
const flash = new engine.THREE.PointLight(0xffaa00, 0, 8)
|
|
81
|
+
engine.scene.add(flash)
|
|
82
|
+
engine._tps = { lastShootTime: 0, isAiming: false, boost: null, flash, flashOff: 0, ammo: 30, reloading: false, lastReloadTime: 0, hitMarkerTime: 0, headshotMarkerTime: 0, killTime: 0, reloadDuration: 2000 }
|
|
83
|
+
this._tps = engine._tps
|
|
84
|
+
engine._tps.juice = makeJuice()
|
|
85
|
+
// Emote wheel: engine.createEmoteWheel (client/app.js exposes client/hud/EmoteWheel.js's factory
|
|
86
|
+
// on engineCtx, matching the existing engine.THREE/engine.scene convention every cross-cutting
|
|
87
|
+
// client utility an app needs already uses) -- apps/ modules cannot cross-directory-import
|
|
88
|
+
// client/ files directly: server-side AppLoader.js real-Node-imports every app file, and the
|
|
89
|
+
// singleplayer Worker's own app loader resolves relative specifiers against a virtual/blob root
|
|
90
|
+
// that does not reach outside apps/ the way a real filesystem path does (confirmed live: a
|
|
91
|
+
// '../../client/...' import failed with 'Invalid relative url' only in the Worker context, while
|
|
92
|
+
// working under plain Node -- the two loaders' resolution semantics genuinely differ).
|
|
93
|
+
try { engine._tps.emoteWheel = engine.createEmoteWheel?.(EMOTE_WHEEL_SLOTS) } catch (_) {}
|
|
94
|
+
engine._tps._lastEmoteDigit = 0
|
|
95
|
+
const ov = engine._tps.overlay = makeOverlay()
|
|
96
|
+
engine._tps._elCross = ov.querySelector('#tps-cross')
|
|
97
|
+
engine._tps._elHit = ov.querySelector('#tps-hit')
|
|
98
|
+
engine._tps._elHitMarks = engine._tps._elHit ? Array.from(engine._tps._elHit.querySelectorAll('.hm')) : []
|
|
99
|
+
engine._tps._elKill = ov.querySelector('#tps-kill')
|
|
100
|
+
engine._tps._elDmgDealt = ov.querySelector('#tps-dmg-dealt')
|
|
101
|
+
engine._tps._elDmgTaken = ov.querySelector('#tps-dmg-taken')
|
|
102
|
+
engine._tps._elShield = ov.querySelector('#tps-shield')
|
|
103
|
+
engine._tps._elVig = ov.querySelector('#tps-vignette')
|
|
104
|
+
},
|
|
105
|
+
onMouseDown(e, engine) { if (e.button === 2 && engine._tps) engine._tps.isAiming = true },
|
|
106
|
+
onMouseUp(e, engine) { if (e.button === 2 && engine._tps) engine._tps.isAiming = false },
|
|
107
|
+
onInput(input, engine) {
|
|
108
|
+
const tps = engine._tps; if (!tps) return
|
|
109
|
+
// Emote wheel: drive the visual selection UI (client/hud/EmoteWheel.js) from live input every
|
|
110
|
+
// call, and commit the send on the RELEASE transition (was held+had a digit selected, now
|
|
111
|
+
// released) -- a real radial-wheel commits once on release, not every frame the digit stays
|
|
112
|
+
// pressed, or the same emote would fire 60x/second while held.
|
|
113
|
+
if (tps.emoteWheel) {
|
|
114
|
+
const wasHeld = tps._wasEmoteWheelHeld || false
|
|
115
|
+
const state = tps.emoteWheel.update(!!input.emoteWheelHeld, input.emoteDigit || 0)
|
|
116
|
+
tps._lastEmoteDigit = state.digit
|
|
117
|
+
if (wasHeld && !input.emoteWheelHeld && tps._lastEmoteDigit > 0) {
|
|
118
|
+
const slot = EMOTE_WHEEL_SLOTS[tps._lastEmoteDigit - 1]
|
|
119
|
+
if (slot) engine.client.sendEmote(slot.code)
|
|
120
|
+
}
|
|
121
|
+
tps._wasEmoteWheelHeld = !!input.emoteWheelHeld
|
|
122
|
+
}
|
|
123
|
+
if (input.reload && !tps.reloading && Date.now() - tps.lastReloadTime > 100) { tps.lastReloadTime = Date.now(); engine.client.sendReload() }
|
|
124
|
+
if (input.shoot && !tps.reloading && tps.ammo > 0 && Date.now() - tps.lastShootTime > 100 / (tps.boost?.fireRate || 1)) {
|
|
125
|
+
tps.lastShootTime = Date.now()
|
|
126
|
+
// must use getLocalState (predicted, matches server) not getRenderState (has a display-smoothing offset the server never sees)
|
|
127
|
+
const local = engine.client.getLocalState?.() || engine.client.state?.players?.find(p => p.id === engine.playerId)
|
|
128
|
+
if (local && local.position) {
|
|
129
|
+
const pos = local.position
|
|
130
|
+
const dir = engine.cam.getAimDirection(pos)
|
|
131
|
+
engine.client.sendFire({ origin: [pos[0], pos[1] + 0.9, pos[2]], direction: dir })
|
|
132
|
+
if (engine.cam?.punch) engine.cam.punch(0.15)
|
|
133
|
+
mobileVibrate(engine, 12)
|
|
134
|
+
// predict recoil pushback locally matching server's shootKnockback=2 exactly, else the shove arrives late and reconciliation corrects it visibly
|
|
135
|
+
const lp = engine.client.getLocalState?.()
|
|
136
|
+
if (lp && lp.velocity && dir) { lp.velocity[0] -= dir[0] * 2; lp.velocity[2] -= dir[2] * 2 }
|
|
137
|
+
const animator = engine.players.getAnimator(engine.playerId)
|
|
138
|
+
if (animator) animator.shoot()
|
|
139
|
+
tps.flash.color.setHex(0xffaa00); tps.flash.position.set(pos[0], pos[1] + 0.5, pos[2]); tps.flash.intensity = 5; tps.flash.distance = 12; tps.flashOff = Date.now() + 60
|
|
140
|
+
tps.ammo = Math.max(0, tps.ammo - 1)
|
|
141
|
+
if (tps.juice) tps.juice.tone(160, 0.05, 0.22, 90)
|
|
142
|
+
// tracer: origin -> full weapon range along dir; a real 'hit' event (below) shortens it to the actual impact point once the server responds, but the muzzle-to-somewhere streak reads correctly even before that RTT.
|
|
143
|
+
if (engine.decals) {
|
|
144
|
+
const muzzle = [pos[0], pos[1] + 0.9, pos[2]]
|
|
145
|
+
engine.decals.spawnTracer(muzzle, [muzzle[0] + dir[0] * 100, muzzle[1] + dir[1] * 100, muzzle[2] + dir[2] * 100])
|
|
146
|
+
}
|
|
147
|
+
// optimistic hit prediction; server 'hit' event de-dupes via tps._predHitAt so it never double-counts
|
|
148
|
+
const pred = predictHit([pos[0], pos[1] + 0.9, pos[2]], dir, engine.client.state?.players, engine.playerId, 0.7)
|
|
149
|
+
if (pred) {
|
|
150
|
+
const tnow = Date.now()
|
|
151
|
+
tps.hitMarkerTime = tnow; tps._predHitAt = tnow
|
|
152
|
+
if (pred.headshot) { tps.headshotMarkerTime = tnow; tps.juice?.tone(1100, 0.08, 0.2) }
|
|
153
|
+
else tps.juice?.tone(820, 0.06, 0.18)
|
|
154
|
+
}
|
|
155
|
+
if (tps.ammo <= 3 && tps.ammo > 0 && Date.now() - (tps.lowAmmoTime || 0) > 300) { tps.lowAmmoTime = Date.now(); tps.juice?.tone(600, 0.04, 0.1) }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
onEvent(payload, engine) {
|
|
160
|
+
const tps = engine._tps
|
|
161
|
+
if (payload.type === 'hit' && payload.target) { engine.players.setExpression(payload.target, 'angry', 0.6); setTimeout(() => engine.players.setExpression(payload.target, 'angry', 0), 500) }
|
|
162
|
+
if (payload.type === 'hit' && tps && payload.shooter === engine.playerId) {
|
|
163
|
+
const now = Date.now()
|
|
164
|
+
tps.hitMarkerTime = now
|
|
165
|
+
// accumulate damage within the 500ms window (not overwrite) so a bunched burst shows the running total, not just the last hit
|
|
166
|
+
const dmgFresh = now - (tps.dmgDealtTime || 0) > 500
|
|
167
|
+
tps.lastDamageDealt = (dmgFresh ? 0 : (tps.lastDamageDealt || 0)) + (payload.damage || 0); tps.dmgDealtTime = now
|
|
168
|
+
// skip the tone if the optimistic prediction already played it within 400ms, but still count the hit
|
|
169
|
+
const justPredicted = tps._predHitAt && now - tps._predHitAt < 400
|
|
170
|
+
tps._predHitAt = 0
|
|
171
|
+
if (payload.headshot) { tps.headshotMarkerTime = now; if (window.__funJuice) window.__funJuice.headshot++; if (!justPredicted) tps.juice?.tone(1100, 0.08, 0.2); mobileVibrate(engine, [15, 30, 15]) }
|
|
172
|
+
else { if (window.__funJuice) window.__funJuice.hit++; if (!justPredicted) tps.juice?.tone(820, 0.06, 0.18); mobileVibrate(engine, 20) }
|
|
173
|
+
if (payload.pos && tps.flash) { tps.flash.position.set(payload.pos[0], payload.pos[1], payload.pos[2]); tps.flash.color.setHex(0xffffff); tps.flash.intensity = 4; tps.flashOff = now + 80 }
|
|
174
|
+
// A player hit doesn't get a scorch decal (blood-optional per roadmap #48 -- this engine has no
|
|
175
|
+
// gore toggle yet, so player hits stay decal-free; only a miss against world geometry decals below).
|
|
176
|
+
// celebrate the kill now on the lethal 'hit' (RTT sooner); the 'death' path below de-dupes against this
|
|
177
|
+
if (payload.health <= 0) { tps._killCreditVictim = payload.target; tps._killCreditAt = now; creditKill(tps) }
|
|
178
|
+
}
|
|
179
|
+
// The local player took damage -> threat flash + floating -N (the dead lastHitTime).
|
|
180
|
+
if (payload.type === 'hit' && tps && payload.target === engine.playerId) {
|
|
181
|
+
tps.lastHitTime = Date.now(); tps.lastDamageTaken = payload.damage || 0
|
|
182
|
+
mobileVibrate(engine, 35)
|
|
183
|
+
// predict knockback locally matching server's impulse exactly so it converges instead of fighting reconciliation
|
|
184
|
+
const local = engine.client.getLocalState?.()
|
|
185
|
+
if (local && local.velocity && payload.dir && payload.knockback) {
|
|
186
|
+
local.velocity[0] += payload.dir[0] * payload.knockback
|
|
187
|
+
local.velocity[2] += payload.dir[2] * payload.knockback
|
|
188
|
+
// recordKnockback restores this on resimulate() replay so replayed inputs can't overwrite the shove
|
|
189
|
+
engine.client.recordKnockback?.([payload.dir[0], 0, payload.dir[2]], payload.knockback, tps.lastHitTime)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (payload.type === 'hit' && tps && payload.shooter !== engine.playerId && payload.target !== engine.playerId && payload.pos && engine.cam) {
|
|
193
|
+
const cp = engine.cam.position, d = Math.hypot(payload.pos[0] - cp.x, payload.pos[2] - cp.z)
|
|
194
|
+
if (d < 60) tps.juice?.tone(140, 0.04, Math.max(0.04, 0.16 * (1 - d / 60)), 85)
|
|
195
|
+
}
|
|
196
|
+
// A shot that hit world geometry (not a player) -- bullet-hole/scorch decal at the impact point.
|
|
197
|
+
if (payload.type === 'world_hit' && engine.decals && payload.pos) engine.decals.spawnDecal(payload.pos, payload.normal)
|
|
198
|
+
if (payload.type === 'aimpunch' && engine.cam?.punch) engine.cam.punch(payload.intensity || 0.3)
|
|
199
|
+
if (payload.type === 'death' && payload.victim) engine.players.setExpression(payload.victim, 'sorrow', 1.0)
|
|
200
|
+
if (payload.type === 'death' && tps && payload.killer === engine.playerId && payload.victim !== engine.playerId) {
|
|
201
|
+
// dedup window scales with RTT: a fixed 1500ms window double-counts a kill when 'death' lags the lethal 'hit' under reordering
|
|
202
|
+
const dedupWin = Math.max(2500, (engine.client.getRTT?.() || 0) * 2.5)
|
|
203
|
+
if (tps._killCreditVictim === payload.victim && Date.now() - (tps._killCreditAt || 0) < dedupWin) {
|
|
204
|
+
tps._killCreditVictim = null
|
|
205
|
+
if (typeof payload.streak === 'number' && payload.streak > 0) { tps.streak = payload.streak; tps.killTime = Date.now() }
|
|
206
|
+
} else creditKill(tps, payload.streak)
|
|
207
|
+
if (typeof payload.killerKills === 'number') tps.kills = payload.killerKills
|
|
208
|
+
tps.lastKillWasHeadshot = !!payload.headshot
|
|
209
|
+
tps.lastKilledPlayer = payload.killerName || 'Player'
|
|
210
|
+
}
|
|
211
|
+
if (payload.type === 'death' && tps && payload.victim === engine.playerId) tps.deathKiller = payload.killer || null
|
|
212
|
+
if (payload.type === 'respawn' && tps) { tps.respawnFadeAt = Date.now(); tps.spawnShieldUntil = Date.now() + (payload.invulnMs || 0); if (typeof payload.ammo === 'number') tps.ammo = payload.ammo; tps.reloading = false; tps.reloadEndTime = null; tps.juice?.tone(420, 0.14, 0.16, 680) }
|
|
213
|
+
if (payload.type === 'empty_click' && tps) { if (window.__funJuice) window.__funJuice.empty++; tps.juice?.tone(90, 0.08, 0.13) }
|
|
214
|
+
if (payload.type === 'hazard_damage' && tps && payload.playerId === engine.playerId) { tps.lastHitTime = Date.now(); tps.juice?.tone(150, 0.1, 0.14) }
|
|
215
|
+
if (payload.type === 'buff_applied' && tps) { tps.boost = { expiresAt: Date.now() + (payload.duration || 45) * 1000, fireRate: payload.fireRate || 1 }; tps.buffFlashAt = Date.now(); tps.juice?.tone(300, 0.18, 0.16, 600) }
|
|
216
|
+
if (payload.type === 'buff_expired' && tps) { tps.boost = null; tps.juice?.tone(440, 0.16, 0.12, 200) }
|
|
217
|
+
if (payload.type === 'reload_start' && tps) { tps.reloading = true; tps.reloadDuration = payload.duration || 2000; tps.reloadEndTime = Date.now() + tps.reloadDuration; tps.juice?.tone(280, 0.05, 0.13); const animator = engine.players?.getAnimator(engine.playerId); if (animator) animator.reload() }
|
|
218
|
+
if (payload.type === 'reload_complete' && tps) { tps.reloading = false; tps.reloadEndTime = null; tps.ammo = tps.magazineSize || 30; tps.juice?.tone(520, 0.05, 0.14) }
|
|
219
|
+
},
|
|
220
|
+
onFrame(dt, engine) {
|
|
221
|
+
const tps = engine._tps; if (!tps) return
|
|
222
|
+
if (tps.boost && Date.now() >= tps.boost.expiresAt) tps.boost = null
|
|
223
|
+
if (tps.flash && tps.flashOff && Date.now() >= tps.flashOff) { tps.flash.intensity = 0; tps.flashOff = 0 }
|
|
224
|
+
engine.players.setAiming(engine.playerId, tps.isAiming)
|
|
225
|
+
const ov = tps.overlay; if (!ov) return
|
|
226
|
+
const now = Date.now()
|
|
227
|
+
const cross = tps._elCross
|
|
228
|
+
if (cross) {
|
|
229
|
+
const scale = now - tps.lastShootTime < 130 ? 1.6 : 1
|
|
230
|
+
if (tps._lastCrossScale !== scale) { tps._lastCrossScale = scale; cross.style.transform = 'translate(-50%,-50%) scale(' + scale + ')' }
|
|
231
|
+
}
|
|
232
|
+
const hit = tps._elHit
|
|
233
|
+
if (hit) {
|
|
234
|
+
const rttPad = Math.min(120, engine.client.getRTT?.() || 0)
|
|
235
|
+
const onHs = now - tps.headshotMarkerTime < 250 + rttPad
|
|
236
|
+
const onHit = now - tps.hitMarkerTime < 150 + rttPad
|
|
237
|
+
const hitOn = (onHit || onHs) ? '1' : '0'
|
|
238
|
+
if (tps._lastHitOn !== hitOn) { tps._lastHitOn = hitOn; hit.style.opacity = hitOn }
|
|
239
|
+
const col = onHs ? '#ffcc33' : '#ffffff'
|
|
240
|
+
if (tps._lastHitCol !== col) { tps._lastHitCol = col; for (const m of tps._elHitMarks) m.style.background = col }
|
|
241
|
+
}
|
|
242
|
+
const kill = tps._elKill
|
|
243
|
+
if (kill) {
|
|
244
|
+
const onKill = now - tps.killTime < Math.min(2200, 1200 + (engine.client.getRTT?.() || 0))
|
|
245
|
+
const streak = tps.streak || 0
|
|
246
|
+
const killText = onKill ? (streak >= 4 ? 'MULTI KILL' : streak === 3 ? 'TRIPLE KILL' : streak === 2 ? 'DOUBLE KILL' : 'KILL') : ''
|
|
247
|
+
if (tps._lastKillText !== killText) { tps._lastKillText = killText; kill.textContent = killText; kill.style.opacity = onKill ? '1' : '0' }
|
|
248
|
+
}
|
|
249
|
+
const dd = tps._elDmgDealt
|
|
250
|
+
if (dd) { const on = now - (tps.dmgDealtTime || 0) < 500; const t = on ? ('+' + (tps.lastDamageDealt || 0)) : ''; if (tps._lastDdText !== t) { tps._lastDdText = t; dd.textContent = t; dd.style.opacity = on ? '1' : '0' } }
|
|
251
|
+
const dtk = tps._elDmgTaken
|
|
252
|
+
if (dtk) { const on = now - (tps.lastHitTime || 0) < 450; const t = on && tps.lastDamageTaken ? ('-' + tps.lastDamageTaken) : ''; if (tps._lastDtkText !== t) { tps._lastDtkText = t; dtk.textContent = t; dtk.style.opacity = on ? '1' : '0' } }
|
|
253
|
+
const shield = tps._elShield
|
|
254
|
+
if (shield) { const rem = (tps.spawnShieldUntil || 0) - now; const op = rem > 0 ? String(Math.min(0.6, rem / 1500 * 0.6)) : '0'; if (tps._lastShieldOp !== op) { tps._lastShieldOp = op; shield.style.opacity = op } }
|
|
255
|
+
const lp = engine.client?.state?.players?.find(p => p.id === engine.playerId)
|
|
256
|
+
const vig = tps._elVig
|
|
257
|
+
{
|
|
258
|
+
const vy = lp?.velocity?.[1] ?? 0, og = !!lp?.onGround
|
|
259
|
+
if (og && tps._wasOnGround === false && (tps._fallVy || 0) < -9 && engine.cam?.punch) engine.cam.punch(0.18)
|
|
260
|
+
tps._fallVy = og ? 0 : vy; tps._wasOnGround = og
|
|
261
|
+
}
|
|
262
|
+
if (vig) {
|
|
263
|
+
const hp = lp?.health ?? 100
|
|
264
|
+
const dmgFlash = now - (tps.lastHitTime || 0) < 220 ? 0.32 : 0
|
|
265
|
+
const lowHp = hp > 0 && hp < 30 ? 0.12 + 0.06 * Math.sin(now / 180) : 0
|
|
266
|
+
const op = String(Math.min(0.4, Math.max(dmgFlash, lowHp)))
|
|
267
|
+
if (tps._lastVigOp !== op) { tps._lastVigOp = op; vig.style.opacity = op }
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
render(ctx) {
|
|
271
|
+
const h = ctx.h; if (!h) return { position: ctx.entity.position }
|
|
272
|
+
const s = ctx.state || {}
|
|
273
|
+
// ctx.kit is threaded in by app.js's top-level import -- apps must not dynamically import (AppLoader sandbox forbids it)
|
|
274
|
+
const local = ctx.players?.find(p => p.id === ctx.engine?.playerId)
|
|
275
|
+
const hp = local?.health ?? 100
|
|
276
|
+
const tps = ctx.engine?._tps
|
|
277
|
+
const boostSec = tps?.boost ? Math.ceil((tps.boost.expiresAt - Date.now()) / 1000) : 0
|
|
278
|
+
const ammo = tps?.ammo ?? 0
|
|
279
|
+
const magazine = s.config?.magazineSize ?? 30
|
|
280
|
+
const reloading = tps?.reloading ?? false
|
|
281
|
+
const reloadDur = tps?.reloadDuration || 2000
|
|
282
|
+
const reloadProgress = reloading && tps?.reloadEndTime ? Math.min(100, Math.round((1 - (tps.reloadEndTime - Date.now()) / reloadDur) * 100)) : 0
|
|
283
|
+
const kills = tps?.kills ?? 0
|
|
284
|
+
const now = Date.now()
|
|
285
|
+
const rttPad = Math.min(120, ctx.engine?.client?.getRTT?.() || 0)
|
|
286
|
+
const hitMarkerActive = now - (tps?.hitMarkerTime || 0) < 150 + rttPad
|
|
287
|
+
const headshot = !!(tps?.headshotMarkerTime && now - tps.headshotMarkerTime < 250 + rttPad)
|
|
288
|
+
const killConfirm = now - (tps?.killTime || 0) < 1500
|
|
289
|
+
const renderGameHud = ctx.kit?.renderGameHud
|
|
290
|
+
return {
|
|
291
|
+
position: ctx.entity.position,
|
|
292
|
+
custom: { game: s.map, mode: s.mode, kills },
|
|
293
|
+
ui: renderGameHud ? renderGameHud(h, { hp, ammo, magazine, reloading, reloadProgress, boostSec, kills, hitMarkerActive, headshot, killConfirm }) : null
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
package/apps/tps-game/index.js
CHANGED
|
@@ -1,545 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { tpsGameServer } from './server-app.js'
|
|
2
|
+
import { tpsGameClient } from './client-app.js'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
{ type: 'damage', color: 0xff3344, emissive: 0xaa0000, buff: { duration: 20, speedMultiplier: 1, fireRateMultiplier: 1, damageMultiplier: 2 } },
|
|
6
|
-
{ type: 'speed', color: 0x33aaff, emissive: 0x0044aa, buff: { duration: 20, speedMultiplier: 1.5, fireRateMultiplier: 1, damageMultiplier: 1 } },
|
|
7
|
-
{ type: 'rapid', color: 0xffcc33, emissive: 0xaa6600, buff: { duration: 20, speedMultiplier: 1, fireRateMultiplier: 2, damageMultiplier: 1 } },
|
|
8
|
-
]
|
|
9
|
-
const POWERUP_RESPAWN_MS = 15000
|
|
10
|
-
const POWERUP_PICKUP_RADIUS = 1.7
|
|
11
|
-
|
|
12
|
-
// Emote wheel (roadmap #78): a short networked CODE (not a free-text clip name -- server allowlist
|
|
13
|
-
// below), mapped to a real clip confirmed present in client/anim-lib.glb (109 real clips inspected
|
|
14
|
-
// live via gltf-transform, not guessed). Order here is the wheel's slot 1..8 (client/hud/EmoteWheel.js
|
|
15
|
-
// lays slots out clockwise from the top in this same array order).
|
|
16
|
-
const EMOTE_CLIPS = new Map([
|
|
17
|
-
['wave', 'Bow'],
|
|
18
|
-
['dance', 'DanceLoop'],
|
|
19
|
-
['nod', 'HeadNod'],
|
|
20
|
-
['victory', 'Victory'],
|
|
21
|
-
['meditate', 'Meditate'],
|
|
22
|
-
['jumpingjacks', 'JumpingJacks'],
|
|
23
|
-
['confused', 'Confused'],
|
|
24
|
-
['sit', 'SittingEnter'],
|
|
25
|
-
])
|
|
26
|
-
const EMOTE_WHEEL_SLOTS = [
|
|
27
|
-
{ code: 'wave', label: 'Bow' },
|
|
28
|
-
{ code: 'dance', label: 'Dance' },
|
|
29
|
-
{ code: 'nod', label: 'Nod' },
|
|
30
|
-
{ code: 'victory', label: 'Victory' },
|
|
31
|
-
{ code: 'meditate', label: 'Meditate' },
|
|
32
|
-
{ code: 'jumpingjacks', label: 'Jumping Jacks' },
|
|
33
|
-
{ code: 'confused', label: 'Confused' },
|
|
34
|
-
{ code: 'sit', label: 'Sit' },
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
function spawnPowerup(ctx, id, def, position) {
|
|
38
|
-
ctx.world.spawn(id, {
|
|
39
|
-
position: [...position], scale: [0.55, 0.55, 0.55],
|
|
40
|
-
custom: { mesh: 'box', powerup: def.type, color: def.color, emissive: def.emissive, emissiveIntensity: 0.7, light: def.color, lightIntensity: 0.9, lightRange: 6, spin: 1.6, hover: 0.35 }
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Feature-detected haptic pulse, gated on MobileControls being the active input path (touch device,
|
|
45
|
-
// not just any browser with the Vibration API) so desktop Chrome/Android-tablet-with-keyboard don't
|
|
46
|
-
// buzz on every shot. No-op server-side (engine.mobileControls is undefined there) and on iOS/desktop
|
|
47
|
-
// (no navigator.vibrate).
|
|
48
|
-
function mobileVibrate(engine, pattern) {
|
|
49
|
-
if (!engine?.mobileControls?.enabled) return
|
|
50
|
-
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') return
|
|
51
|
-
navigator.vibrate(pattern)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// caller must de-dupe: called from both the optimistic 'hit' path and the authoritative 'death' path
|
|
55
|
-
function creditKill(tps, authStreak) {
|
|
56
|
-
const now = Date.now()
|
|
57
|
-
tps.killTime = now; tps.kills = (tps.kills || 0) + 1
|
|
58
|
-
if (typeof window !== 'undefined' && window.__funJuice) window.__funJuice.kill++
|
|
59
|
-
tps.streak = (typeof authStreak === 'number' && authStreak > 0) ? authStreak : ((now - (tps.lastKillTime || 0) < 3000) ? (tps.streak || 1) + 1 : 1)
|
|
60
|
-
tps.lastKillTime = now
|
|
61
|
-
tps.juice?.tone(420, 0.28, 0.2, 760 + Math.min(4, tps.streak - 1) * 120)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// mirrors server.js handleFire's hit geometry (capsule +0.9, radius 0.6) for client-side prediction; server stays authoritative
|
|
65
|
-
export function predictHit(origin, dir, players, selfId, headshotZone) {
|
|
66
|
-
if (!origin || !dir || !players) return null
|
|
67
|
-
for (const p of players) {
|
|
68
|
-
if (!p || p.id === selfId || !p.position) continue
|
|
69
|
-
if ((p.health ?? 100) <= 0) continue
|
|
70
|
-
const tp = p.position
|
|
71
|
-
const toX = tp[0] - origin[0], toY = tp[1] + 0.9 - origin[1], toZ = tp[2] - origin[2]
|
|
72
|
-
const dot = toX * dir[0] + toY * dir[1] + toZ * dir[2]
|
|
73
|
-
if (dot < 0 || dot > 1000) continue
|
|
74
|
-
const px = origin[0] + dir[0] * dot, py = origin[1] + dir[1] * dot, pz = origin[2] + dir[2] * dot
|
|
75
|
-
const ddx = px - tp[0], ddy = py - (tp[1] + 0.9), ddz = pz - tp[2]
|
|
76
|
-
const d2 = ddx * ddx + ddy * ddy + ddz * ddz
|
|
77
|
-
if (d2 > 0.36) continue
|
|
78
|
-
return { headshot: ((py - tp[1]) / 1.8) >= (headshotZone ?? 0.7) }
|
|
79
|
-
}
|
|
80
|
-
return null
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function makeJuice() {
|
|
84
|
-
let actx = null
|
|
85
|
-
if (typeof window !== 'undefined' && !window.__funJuice) {
|
|
86
|
-
window.__funJuice = { tones: [], hit: 0, headshot: 0, kill: 0, empty: 0, muted: false }
|
|
87
|
-
}
|
|
88
|
-
const ensure = () => {
|
|
89
|
-
if (typeof window === 'undefined') return null
|
|
90
|
-
if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)() } catch (e) { return null } }
|
|
91
|
-
if (actx.state === 'suspended') { try { actx.resume() } catch (e) {} }
|
|
92
|
-
return actx
|
|
93
|
-
}
|
|
94
|
-
const tone = (freq, dur, vol = 0.18, rampTo = freq) => {
|
|
95
|
-
if (window.__funJuice) { window.__funJuice.tones.push({ freq, dur }); if (window.__funJuice.muted) return }
|
|
96
|
-
const a = ensure(); if (!a) return
|
|
97
|
-
const t0 = a.currentTime
|
|
98
|
-
const osc = a.createOscillator(); const g = a.createGain()
|
|
99
|
-
osc.frequency.setValueAtTime(freq, t0)
|
|
100
|
-
if (rampTo !== freq) osc.frequency.exponentialRampToValueAtTime(Math.max(1, rampTo), t0 + dur)
|
|
101
|
-
g.gain.setValueAtTime(0.0001, t0)
|
|
102
|
-
g.gain.exponentialRampToValueAtTime(Math.min(0.3, vol), t0 + Math.min(0.008, dur * 0.3))
|
|
103
|
-
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
|
|
104
|
-
osc.connect(g).connect(a.destination)
|
|
105
|
-
osc.start(t0); osc.stop(t0 + dur + 0.02)
|
|
106
|
-
}
|
|
107
|
-
return { tone }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function makeOverlay() {
|
|
111
|
-
if (typeof document === 'undefined') return null
|
|
112
|
-
let root = document.getElementById('tps-juice')
|
|
113
|
-
if (root) return root
|
|
114
|
-
root = document.createElement('div')
|
|
115
|
-
root.id = 'tps-juice'
|
|
116
|
-
root.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:50;font-family:system-ui,sans-serif'
|
|
117
|
-
root.innerHTML =
|
|
118
|
-
'<div id="tps-cross" style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) scale(1);transition:transform .08s ease-out;width:18px;height:18px">' +
|
|
119
|
-
'<span style="position:absolute;left:8px;top:0;width:2px;height:18px;background:rgba(255,255,255,.65)"></span>' +
|
|
120
|
-
'<span style="position:absolute;left:0;top:8px;width:18px;height:2px;background:rgba(255,255,255,.65)"></span>' +
|
|
121
|
-
'</div>' +
|
|
122
|
-
'<div id="tps-hit" style="position:absolute;left:50%;top:50%;width:0;height:0;transform:translate(-50%,-50%) rotate(45deg);opacity:0;transition:opacity .05s">' +
|
|
123
|
-
'<span class="hm" style="position:absolute;left:-1px;top:-13px;width:2px;height:8px"></span>' +
|
|
124
|
-
'<span class="hm" style="position:absolute;left:-1px;top:5px;width:2px;height:8px"></span>' +
|
|
125
|
-
'<span class="hm" style="position:absolute;left:-13px;top:-1px;width:8px;height:2px"></span>' +
|
|
126
|
-
'<span class="hm" style="position:absolute;left:5px;top:-1px;width:8px;height:2px"></span>' +
|
|
127
|
-
'</div>' +
|
|
128
|
-
'<div id="tps-kill" style="position:absolute;left:50%;top:36%;transform:translate(-50%,-50%);color:#ffcc33;font-weight:800;font-size:34px;letter-spacing:2px;text-shadow:0 2px 8px #000;opacity:0;transition:opacity .15s"></div>' +
|
|
129
|
-
'<div id="tps-dmg-dealt" style="position:absolute;left:52%;top:47%;color:#ffe27a;font-weight:700;font-size:17px;text-shadow:0 1px 3px #000;opacity:0;transition:opacity .05s"></div>' +
|
|
130
|
-
'<div id="tps-dmg-taken" style="position:absolute;left:50%;top:41%;transform:translate(-50%,-50%);color:#ff7a7a;font-weight:800;font-size:24px;text-shadow:0 2px 6px #000;opacity:0;transition:opacity .1s"></div>' +
|
|
131
|
-
'<div id="tps-shield" style="position:absolute;inset:0;opacity:0;transition:opacity .15s;box-shadow:inset 0 0 90px 18px rgba(80,180,255,0.45)"></div>' +
|
|
132
|
-
'<div id="tps-vignette" style="position:absolute;inset:0;opacity:0;transition:opacity .12s;box-shadow:inset 0 0 140px 40px rgba(200,0,0,0.9);background:radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(160,0,0,0.35) 100%)"></div>'
|
|
133
|
-
document.body.appendChild(root)
|
|
134
|
-
return root
|
|
135
|
-
}
|
|
4
|
+
export { predictHit } from './shared.js'
|
|
136
5
|
|
|
137
6
|
export default {
|
|
138
|
-
server:
|
|
139
|
-
|
|
140
|
-
ctx.state.map = 'schwust'
|
|
141
|
-
ctx.state.mode = 'ffa'
|
|
142
|
-
ctx.state.config = { respawnTime: 1.5, health: 100, damagePerHit: 20, headshotMultiplier: 2.5, headshotZone: 0.7, hitKnockback: 4, shootKnockback: 2, magazineSize: 30, reloadTime: 2000, spawnInvulnMs: 1500 }
|
|
143
|
-
ctx.state.invuln = new Map()
|
|
144
|
-
// Placed spawn-point entities (apps/spawn-point) take priority over the raycast grid --
|
|
145
|
-
// a maker who drops markers gets exactly those; the grid is only the no-markers-placed
|
|
146
|
-
// fallback so existing worlds without markers keep working unchanged.
|
|
147
|
-
const placedSpawns = collectSpawnPoints(ctx)
|
|
148
|
-
ctx.state.spawnPoints = placedSpawns.length > 0 ? placedSpawns : findSpawnPoints(ctx)
|
|
149
|
-
ctx.state.playerStats = new Map()
|
|
150
|
-
// Cumulative session/across-restart scoreboard, keyed by durable player name -- see server.js
|
|
151
|
-
// loadScoreboard/persistPlayerStat. Awaited here (AppRuntime awaits server.setup) so it is fully
|
|
152
|
-
// populated before the FIRST player_join can look a name up.
|
|
153
|
-
await loadScoreboard(ctx)
|
|
154
|
-
// Register the debounced scoreboard write's flush with the engine's graceful-shutdown registry
|
|
155
|
-
// (ctx.onShutdown, src/apps/AppContext.js/AppRuntime.js) so a SIGINT/SIGTERM within the 500ms
|
|
156
|
-
// debounce window (see scheduleScoreboardPersist in server.js) doesn't silently drop the last
|
|
157
|
-
// burst of kill/death stat changes -- mirrors ctx.placedModelStorage.flush()'s own shutdown wiring.
|
|
158
|
-
ctx.onShutdown(() => flushScoreboard(ctx))
|
|
159
|
-
ctx.state.respawning = new Map()
|
|
160
|
-
ctx.state.buffs = new Map()
|
|
161
|
-
ctx.state.ammo = new Map()
|
|
162
|
-
ctx.state.reloading = new Map()
|
|
163
|
-
ctx.state.lastEmoteAt = new Map()
|
|
164
|
-
ctx.state.started = Date.now()
|
|
165
|
-
ctx.state.gameTime = 0
|
|
166
|
-
ctx.state.fallTimers = new Map()
|
|
167
|
-
ctx.state.killStreaks = new Map()
|
|
168
|
-
ctx.state.powerups = new Map()
|
|
169
|
-
const sps = ctx.state.spawnPoints
|
|
170
|
-
const picks = (sps && sps.length >= POWERUP_DEFS.length)
|
|
171
|
-
? POWERUP_DEFS.map((_, i) => sps[Math.floor((i + 1) * sps.length / (POWERUP_DEFS.length + 1))])
|
|
172
|
-
: POWERUP_DEFS.map((_, i) => [i * 8 - 8, 3, 0])
|
|
173
|
-
POWERUP_DEFS.forEach((def, i) => {
|
|
174
|
-
const p = picks[i], pos = [p[0], p[1] + 0.6, p[2]], id = `powerup_${def.type}`
|
|
175
|
-
ctx.state.powerups.set(id, { def, position: pos, active: true, respawnAt: 0 })
|
|
176
|
-
spawnPowerup(ctx, id, def, pos)
|
|
177
|
-
})
|
|
178
|
-
ctx.bus.on('powerup.collected', (event) => {
|
|
179
|
-
const d = event.data
|
|
180
|
-
ctx.state.buffs.set(d.playerId, { expiresAt: Date.now() + d.duration * 1000, speed: d.speedMultiplier, fireRate: d.fireRateMultiplier, damage: d.damageMultiplier })
|
|
181
|
-
ctx.players.send(d.playerId, { type: 'buff_applied', duration: d.duration, speed: d.speedMultiplier, fireRate: d.fireRateMultiplier, damage: d.damageMultiplier })
|
|
182
|
-
})
|
|
183
|
-
console.log(`[tps-game] ${ctx.state.spawnPoints.length} spawn points validated`)
|
|
184
|
-
},
|
|
185
|
-
|
|
186
|
-
update(ctx, dt) {
|
|
187
|
-
ctx.state.gameTime = (Date.now() - ctx.state.started) / 1000
|
|
188
|
-
const now = Date.now()
|
|
189
|
-
// Defensive re-init: setup() constructs ctx.state.buffs as a real Map. The two real root causes
|
|
190
|
-
// that could hand back a non-Map here are both fixed upstream now: (1) the init-order race, where
|
|
191
|
-
// update() could run before setup()'s async loadScoreboard await resolves -- see AppRuntime.js's
|
|
192
|
-
// _pendingSetupIds skip in _rebuildUpdateList/_rebuildCollisionList; (2) a Map silently downgrading
|
|
193
|
-
// to a plain object across any restoreGameState/WorldPersistence round-trip, since a naive
|
|
194
|
-
// JSON.parse(JSON.stringify(...)) has no Map wire type -- fixed via AppRuntime.js's tagged
|
|
195
|
-
// cloneAppState (Map/Set-preserving replacer/reviver), used for entity._appState (ctx.state)
|
|
196
|
-
// specifically. This guard stays as cheap, top-of-update defense-in-depth against any future
|
|
197
|
-
// write path this loop hasn't been audited against yet, not because either known cause is still open.
|
|
198
|
-
if (!(ctx.state.buffs instanceof Map)) ctx.state.buffs = new Map()
|
|
199
|
-
for (const [pid, buff] of ctx.state.buffs) {
|
|
200
|
-
if (now >= buff.expiresAt) { ctx.state.buffs.delete(pid); ctx.players.send(pid, { type: 'buff_expired' }) }
|
|
201
|
-
else { const player = ctx.players.getById(pid); if (player?.state) player.state.health = Math.min(ctx.state.config.health, (player.state.health ?? ctx.state.config.health) + (ctx.state.config.health / 10) * dt) }
|
|
202
|
-
}
|
|
203
|
-
const allPlayers = ctx.players.getAll()
|
|
204
|
-
for (const player of allPlayers) {
|
|
205
|
-
if (!player.state || ctx.state.respawning.has(player.id)) continue
|
|
206
|
-
if ((player.state.health ?? ctx.state.config.health) <= 0) continue
|
|
207
|
-
const y = player.state.position?.[1] ?? 0
|
|
208
|
-
if (y < -20) {
|
|
209
|
-
const t = (ctx.state.fallTimers.get(player.id) || 0) + dt
|
|
210
|
-
ctx.state.fallTimers.set(player.id, t)
|
|
211
|
-
if (t >= 0.5) { player.state.health = 0; ctx.state.respawning.set(player.id, { respawnAt: now + ctx.state.config.respawnTime * 1000, killer: null }); ctx.network.broadcast({ type: 'death', victim: player.id, killer: null, cause: 'fall' }); ctx.state.fallTimers.delete(player.id) }
|
|
212
|
-
} else { ctx.state.fallTimers.delete(player.id) }
|
|
213
|
-
}
|
|
214
|
-
// Same defensive re-init as ctx.state.buffs above (init-order race / Map->plain-object downgrade
|
|
215
|
-
// across a restoreGameState/WorldPersistence round-trip) -- powerups hit the identical hazard since
|
|
216
|
-
// it is also a Map constructed once in setup() with no per-tick type guard until now.
|
|
217
|
-
if (!(ctx.state.powerups instanceof Map)) ctx.state.powerups = new Map()
|
|
218
|
-
{
|
|
219
|
-
for (const [id, pu] of ctx.state.powerups) {
|
|
220
|
-
if (pu.active) {
|
|
221
|
-
for (const player of allPlayers) {
|
|
222
|
-
if (!player.state || ctx.state.respawning.has(player.id)) continue
|
|
223
|
-
if ((player.state.health ?? ctx.state.config.health) <= 0) continue
|
|
224
|
-
const pp = player.state.position; if (!pp) continue
|
|
225
|
-
const dx = pp[0] - pu.position[0], dy = pp[1] - pu.position[1], dz = pp[2] - pu.position[2]
|
|
226
|
-
if (dx * dx + dy * dy + dz * dz <= POWERUP_PICKUP_RADIUS * POWERUP_PICKUP_RADIUS) {
|
|
227
|
-
ctx.bus.emit('powerup.collected', { playerId: player.id, duration: pu.def.buff.duration, speedMultiplier: pu.def.buff.speedMultiplier, fireRateMultiplier: pu.def.buff.fireRateMultiplier, damageMultiplier: pu.def.buff.damageMultiplier })
|
|
228
|
-
ctx.world.destroy(id)
|
|
229
|
-
pu.active = false; pu.respawnAt = now + POWERUP_RESPAWN_MS
|
|
230
|
-
break
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
} else if (now >= pu.respawnAt) {
|
|
234
|
-
spawnPowerup(ctx, id, pu.def, pu.position); pu.active = true
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
for (const [pid, data] of ctx.state.respawning) {
|
|
239
|
-
if (now < data.respawnAt) continue
|
|
240
|
-
const sp = getAvailableSpawnPoint(ctx, ctx.state.spawnPoints)
|
|
241
|
-
const player = ctx.players.getById(pid)
|
|
242
|
-
if (player?.state) { player.state.health = ctx.state.config.health; player.state.velocity = [0, 0, 0]; ctx.players.setPosition(pid, sp) }
|
|
243
|
-
ctx.state.invuln.set(pid, now + (ctx.state.config.spawnInvulnMs || 0))
|
|
244
|
-
// respawn must reset ammo/reload same as player_join, else stale magazine silently rejects every shot client thinks it has
|
|
245
|
-
ctx.state.ammo.set(pid, ctx.state.config.magazineSize)
|
|
246
|
-
ctx.state.reloading.delete(pid)
|
|
247
|
-
ctx.players.send(pid, { type: 'respawn', position: sp, health: ctx.state.config.health, ammo: ctx.state.config.magazineSize, invulnMs: ctx.state.config.spawnInvulnMs })
|
|
248
|
-
ctx.state.respawning.delete(pid)
|
|
249
|
-
}
|
|
250
|
-
},
|
|
251
|
-
|
|
252
|
-
onMessage(ctx, msg) {
|
|
253
|
-
if (!msg) return
|
|
254
|
-
if (msg.type === 'player_join') {
|
|
255
|
-
const p = ctx.players.getById(msg.playerId)
|
|
256
|
-
// must not force health on reconnect: RECONNECT_ACK already restored it, forcing max would res a mid-blip death
|
|
257
|
-
if (p?.state && !msg.reconnected) p.state.health = ctx.state.config.health
|
|
258
|
-
if (!msg.reconnected || !ctx.state.playerStats.has(msg.playerId)) {
|
|
259
|
-
// Restore cumulative kills/deaths/damage from the durable by-name scoreboard (see server.js
|
|
260
|
-
// loadScoreboard/persistPlayerStat) if this player's name has a saved record -- a fresh Map
|
|
261
|
-
// entry every join/reconnect used to silently reset the live in-memory stats to zero even
|
|
262
|
-
// though the durable record on disk still had the player's real cumulative totals.
|
|
263
|
-
const name = p?.name || `Player ${msg.playerId}`
|
|
264
|
-
const saved = ctx.state.scoreboardByName?.[name]
|
|
265
|
-
ctx.state.playerStats.set(msg.playerId, saved ? { kills: saved.kills || 0, deaths: saved.deaths || 0, damage: saved.damage || 0 } : { kills: 0, deaths: 0, damage: 0 })
|
|
266
|
-
}
|
|
267
|
-
ctx.state.ammo.set(msg.playerId, ctx.state.config.magazineSize)
|
|
268
|
-
ctx.state.reloading.delete(msg.playerId)
|
|
269
|
-
}
|
|
270
|
-
if (msg.type === 'player_leave') {
|
|
271
|
-
// Final mirror-and-persist BEFORE dropping the in-memory entry -- covers the case where the
|
|
272
|
-
// last stat change since the previous debounce fired (e.g. a damage tick from the shot that
|
|
273
|
-
// killed the leaving player) hasn't hit disk yet. The durable by-name record is what survives;
|
|
274
|
-
// the in-memory playerStats Map is keyed by this ephemeral playerId and is safe to drop, since
|
|
275
|
-
// a future rejoin re-seeds from ctx.state.scoreboardByName (by name) on player_join above.
|
|
276
|
-
persistPlayerStat(ctx, msg.playerId)
|
|
277
|
-
ctx.state.playerStats.delete(msg.playerId); ctx.state.respawning.delete(msg.playerId)
|
|
278
|
-
ctx.state.fallTimers.delete(msg.playerId); ctx.state.ammo.delete(msg.playerId); ctx.state.reloading.delete(msg.playerId); ctx.state.invuln.delete(msg.playerId)
|
|
279
|
-
}
|
|
280
|
-
if (msg.type === 'reload') {
|
|
281
|
-
const playerId = msg.senderId || msg.playerId
|
|
282
|
-
if (ctx.state.reloading.has(playerId) || (ctx.state.ammo.get(playerId) ?? 0) >= ctx.state.config.magazineSize) return
|
|
283
|
-
ctx.state.reloading.set(playerId, { startTime: Date.now() })
|
|
284
|
-
ctx.players.send(playerId, { type: 'reload_start', duration: ctx.state.config.reloadTime })
|
|
285
|
-
setTimeout(() => { ctx.state.ammo.set(playerId, ctx.state.config.magazineSize); ctx.state.reloading.delete(playerId); ctx.players.send(playerId, { type: 'reload_complete' }) }, ctx.state.config.reloadTime)
|
|
286
|
-
}
|
|
287
|
-
if (msg.type === 'emote') {
|
|
288
|
-
// Server-authoritative allowlist: never trust a client-supplied clip name directly into
|
|
289
|
-
// playAnimation (an arbitrary string reaching the animation library lookup is low-risk here
|
|
290
|
-
// since it only no-ops on a miss, but an explicit allowlist is the correct discipline for any
|
|
291
|
-
// client-triggered broadcast -- matches roadmap #78's own 'networked emote codes' framing,
|
|
292
|
-
// a CODE the client sends, not a free-text clip name). Rate-limited per player (reuses the
|
|
293
|
-
// same reload-style timestamp-gate pattern as the fire/reload handlers above) so a client
|
|
294
|
-
// can't spam a broadcast to every other connected player.
|
|
295
|
-
const playerId = msg.senderId || msg.playerId
|
|
296
|
-
const now = Date.now()
|
|
297
|
-
const lastEmote = ctx.state.lastEmoteAt.get(playerId) || 0
|
|
298
|
-
if (now - lastEmote < 800) return
|
|
299
|
-
if (!EMOTE_CLIPS.has(msg.code)) return
|
|
300
|
-
ctx.state.lastEmoteAt.set(playerId, now)
|
|
301
|
-
ctx.players.playAnimation(playerId, EMOTE_CLIPS.get(msg.code), { loop: false })
|
|
302
|
-
}
|
|
303
|
-
if (msg.type === 'fire') {
|
|
304
|
-
const shooterId = msg.senderId || msg.shooterId
|
|
305
|
-
if (ctx.state.reloading.has(shooterId)) return
|
|
306
|
-
const ammo = ctx.state.ammo.get(shooterId) ?? 0
|
|
307
|
-
if (ammo <= 0) { ctx.players.send(shooterId, { type: 'empty_click' }); return }
|
|
308
|
-
ctx.state.ammo.set(shooterId, ammo - 1)
|
|
309
|
-
const shooter = ctx.players.getById(shooterId)
|
|
310
|
-
const pos = shooter?.state?.position || [0, 0, 0]
|
|
311
|
-
const origin = [pos[0], pos[1] + 0.9, pos[2]]
|
|
312
|
-
// msg.clientTime is expressed in ESTIMATED SERVER CLOCK time (BaseClient.sendFire adds the
|
|
313
|
-
// client's NTP-style clock offset before sending), so Date.now()-msg.clientTime here is a real
|
|
314
|
-
// one-way client->server delay estimate, not the old raw-clock-skew-conflated value.
|
|
315
|
-
const latencyMs = msg.clientTime ? Math.min(600, Math.max(0, Date.now() - msg.clientTime)) : 0
|
|
316
|
-
const fireData = { shooterId, origin, direction: msg.direction, latencyMs }
|
|
317
|
-
ctx.bus.emit('combat.fire', fireData)
|
|
318
|
-
if (shooter?.state) { shooter.state.velocity[0] -= msg.direction[0] * ctx.state.config.shootKnockback; shooter.state.velocity[2] -= msg.direction[2] * ctx.state.config.shootKnockback }
|
|
319
|
-
ctx.players.send(shooterId, { type: 'aimpunch', intensity: 0.3 })
|
|
320
|
-
handleFire(ctx, fireData)
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
},
|
|
324
|
-
|
|
325
|
-
client: {
|
|
326
|
-
_tps: null,
|
|
327
|
-
setup(engine) {
|
|
328
|
-
const flash = new engine.THREE.PointLight(0xffaa00, 0, 8)
|
|
329
|
-
engine.scene.add(flash)
|
|
330
|
-
engine._tps = { lastShootTime: 0, isAiming: false, boost: null, flash, flashOff: 0, ammo: 30, reloading: false, lastReloadTime: 0, hitMarkerTime: 0, headshotMarkerTime: 0, killTime: 0, reloadDuration: 2000 }
|
|
331
|
-
this._tps = engine._tps
|
|
332
|
-
engine._tps.juice = makeJuice()
|
|
333
|
-
// Emote wheel: engine.createEmoteWheel (client/app.js exposes client/hud/EmoteWheel.js's factory
|
|
334
|
-
// on engineCtx, matching the existing engine.THREE/engine.scene convention every cross-cutting
|
|
335
|
-
// client utility an app needs already uses) -- apps/ modules cannot cross-directory-import
|
|
336
|
-
// client/ files directly: server-side AppLoader.js real-Node-imports every app file, and the
|
|
337
|
-
// singleplayer Worker's own app loader resolves relative specifiers against a virtual/blob root
|
|
338
|
-
// that does not reach outside apps/ the way a real filesystem path does (confirmed live: a
|
|
339
|
-
// '../../client/...' import failed with 'Invalid relative url' only in the Worker context, while
|
|
340
|
-
// working under plain Node -- the two loaders' resolution semantics genuinely differ).
|
|
341
|
-
try { engine._tps.emoteWheel = engine.createEmoteWheel?.(EMOTE_WHEEL_SLOTS) } catch (_) {}
|
|
342
|
-
engine._tps._lastEmoteDigit = 0
|
|
343
|
-
const ov = engine._tps.overlay = makeOverlay()
|
|
344
|
-
engine._tps._elCross = ov.querySelector('#tps-cross')
|
|
345
|
-
engine._tps._elHit = ov.querySelector('#tps-hit')
|
|
346
|
-
engine._tps._elHitMarks = engine._tps._elHit ? Array.from(engine._tps._elHit.querySelectorAll('.hm')) : []
|
|
347
|
-
engine._tps._elKill = ov.querySelector('#tps-kill')
|
|
348
|
-
engine._tps._elDmgDealt = ov.querySelector('#tps-dmg-dealt')
|
|
349
|
-
engine._tps._elDmgTaken = ov.querySelector('#tps-dmg-taken')
|
|
350
|
-
engine._tps._elShield = ov.querySelector('#tps-shield')
|
|
351
|
-
engine._tps._elVig = ov.querySelector('#tps-vignette')
|
|
352
|
-
},
|
|
353
|
-
onMouseDown(e, engine) { if (e.button === 2 && engine._tps) engine._tps.isAiming = true },
|
|
354
|
-
onMouseUp(e, engine) { if (e.button === 2 && engine._tps) engine._tps.isAiming = false },
|
|
355
|
-
onInput(input, engine) {
|
|
356
|
-
const tps = engine._tps; if (!tps) return
|
|
357
|
-
// Emote wheel: drive the visual selection UI (client/hud/EmoteWheel.js) from live input every
|
|
358
|
-
// call, and commit the send on the RELEASE transition (was held+had a digit selected, now
|
|
359
|
-
// released) -- a real radial-wheel commits once on release, not every frame the digit stays
|
|
360
|
-
// pressed, or the same emote would fire 60x/second while held.
|
|
361
|
-
if (tps.emoteWheel) {
|
|
362
|
-
const wasHeld = tps._wasEmoteWheelHeld || false
|
|
363
|
-
const state = tps.emoteWheel.update(!!input.emoteWheelHeld, input.emoteDigit || 0)
|
|
364
|
-
tps._lastEmoteDigit = state.digit
|
|
365
|
-
if (wasHeld && !input.emoteWheelHeld && tps._lastEmoteDigit > 0) {
|
|
366
|
-
const slot = EMOTE_WHEEL_SLOTS[tps._lastEmoteDigit - 1]
|
|
367
|
-
if (slot) engine.client.sendEmote(slot.code)
|
|
368
|
-
}
|
|
369
|
-
tps._wasEmoteWheelHeld = !!input.emoteWheelHeld
|
|
370
|
-
}
|
|
371
|
-
if (input.reload && !tps.reloading && Date.now() - tps.lastReloadTime > 100) { tps.lastReloadTime = Date.now(); engine.client.sendReload() }
|
|
372
|
-
if (input.shoot && !tps.reloading && tps.ammo > 0 && Date.now() - tps.lastShootTime > 100 / (tps.boost?.fireRate || 1)) {
|
|
373
|
-
tps.lastShootTime = Date.now()
|
|
374
|
-
// must use getLocalState (predicted, matches server) not getRenderState (has a display-smoothing offset the server never sees)
|
|
375
|
-
const local = engine.client.getLocalState?.() || engine.client.state?.players?.find(p => p.id === engine.playerId)
|
|
376
|
-
if (local && local.position) {
|
|
377
|
-
const pos = local.position
|
|
378
|
-
const dir = engine.cam.getAimDirection(pos)
|
|
379
|
-
engine.client.sendFire({ origin: [pos[0], pos[1] + 0.9, pos[2]], direction: dir })
|
|
380
|
-
if (engine.cam?.punch) engine.cam.punch(0.15)
|
|
381
|
-
mobileVibrate(engine, 12)
|
|
382
|
-
// predict recoil pushback locally matching server's shootKnockback=2 exactly, else the shove arrives late and reconciliation corrects it visibly
|
|
383
|
-
const lp = engine.client.getLocalState?.()
|
|
384
|
-
if (lp && lp.velocity && dir) { lp.velocity[0] -= dir[0] * 2; lp.velocity[2] -= dir[2] * 2 }
|
|
385
|
-
const animator = engine.players.getAnimator(engine.playerId)
|
|
386
|
-
if (animator) animator.shoot()
|
|
387
|
-
tps.flash.color.setHex(0xffaa00); tps.flash.position.set(pos[0], pos[1] + 0.5, pos[2]); tps.flash.intensity = 5; tps.flash.distance = 12; tps.flashOff = Date.now() + 60
|
|
388
|
-
tps.ammo = Math.max(0, tps.ammo - 1)
|
|
389
|
-
if (tps.juice) tps.juice.tone(160, 0.05, 0.22, 90)
|
|
390
|
-
// tracer: origin -> full weapon range along dir; a real 'hit' event (below) shortens it to the actual impact point once the server responds, but the muzzle-to-somewhere streak reads correctly even before that RTT.
|
|
391
|
-
if (engine.decals) {
|
|
392
|
-
const muzzle = [pos[0], pos[1] + 0.9, pos[2]]
|
|
393
|
-
engine.decals.spawnTracer(muzzle, [muzzle[0] + dir[0] * 100, muzzle[1] + dir[1] * 100, muzzle[2] + dir[2] * 100])
|
|
394
|
-
}
|
|
395
|
-
// optimistic hit prediction; server 'hit' event de-dupes via tps._predHitAt so it never double-counts
|
|
396
|
-
const pred = predictHit([pos[0], pos[1] + 0.9, pos[2]], dir, engine.client.state?.players, engine.playerId, 0.7)
|
|
397
|
-
if (pred) {
|
|
398
|
-
const tnow = Date.now()
|
|
399
|
-
tps.hitMarkerTime = tnow; tps._predHitAt = tnow
|
|
400
|
-
if (pred.headshot) { tps.headshotMarkerTime = tnow; tps.juice?.tone(1100, 0.08, 0.2) }
|
|
401
|
-
else tps.juice?.tone(820, 0.06, 0.18)
|
|
402
|
-
}
|
|
403
|
-
if (tps.ammo <= 3 && tps.ammo > 0 && Date.now() - (tps.lowAmmoTime || 0) > 300) { tps.lowAmmoTime = Date.now(); tps.juice?.tone(600, 0.04, 0.1) }
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
},
|
|
407
|
-
onEvent(payload, engine) {
|
|
408
|
-
const tps = engine._tps
|
|
409
|
-
if (payload.type === 'hit' && payload.target) { engine.players.setExpression(payload.target, 'angry', 0.6); setTimeout(() => engine.players.setExpression(payload.target, 'angry', 0), 500) }
|
|
410
|
-
if (payload.type === 'hit' && tps && payload.shooter === engine.playerId) {
|
|
411
|
-
const now = Date.now()
|
|
412
|
-
tps.hitMarkerTime = now
|
|
413
|
-
// accumulate damage within the 500ms window (not overwrite) so a bunched burst shows the running total, not just the last hit
|
|
414
|
-
const dmgFresh = now - (tps.dmgDealtTime || 0) > 500
|
|
415
|
-
tps.lastDamageDealt = (dmgFresh ? 0 : (tps.lastDamageDealt || 0)) + (payload.damage || 0); tps.dmgDealtTime = now
|
|
416
|
-
// skip the tone if the optimistic prediction already played it within 400ms, but still count the hit
|
|
417
|
-
const justPredicted = tps._predHitAt && now - tps._predHitAt < 400
|
|
418
|
-
tps._predHitAt = 0
|
|
419
|
-
if (payload.headshot) { tps.headshotMarkerTime = now; if (window.__funJuice) window.__funJuice.headshot++; if (!justPredicted) tps.juice?.tone(1100, 0.08, 0.2); mobileVibrate(engine, [15, 30, 15]) }
|
|
420
|
-
else { if (window.__funJuice) window.__funJuice.hit++; if (!justPredicted) tps.juice?.tone(820, 0.06, 0.18); mobileVibrate(engine, 20) }
|
|
421
|
-
if (payload.pos && tps.flash) { tps.flash.position.set(payload.pos[0], payload.pos[1], payload.pos[2]); tps.flash.color.setHex(0xffffff); tps.flash.intensity = 4; tps.flashOff = now + 80 }
|
|
422
|
-
// A player hit doesn't get a scorch decal (blood-optional per roadmap #48 -- this engine has no
|
|
423
|
-
// gore toggle yet, so player hits stay decal-free; only a miss against world geometry decals below).
|
|
424
|
-
// celebrate the kill now on the lethal 'hit' (RTT sooner); the 'death' path below de-dupes against this
|
|
425
|
-
if (payload.health <= 0) { tps._killCreditVictim = payload.target; tps._killCreditAt = now; creditKill(tps) }
|
|
426
|
-
}
|
|
427
|
-
// The local player took damage -> threat flash + floating -N (the dead lastHitTime).
|
|
428
|
-
if (payload.type === 'hit' && tps && payload.target === engine.playerId) {
|
|
429
|
-
tps.lastHitTime = Date.now(); tps.lastDamageTaken = payload.damage || 0
|
|
430
|
-
mobileVibrate(engine, 35)
|
|
431
|
-
// predict knockback locally matching server's impulse exactly so it converges instead of fighting reconciliation
|
|
432
|
-
const local = engine.client.getLocalState?.()
|
|
433
|
-
if (local && local.velocity && payload.dir && payload.knockback) {
|
|
434
|
-
local.velocity[0] += payload.dir[0] * payload.knockback
|
|
435
|
-
local.velocity[2] += payload.dir[2] * payload.knockback
|
|
436
|
-
// recordKnockback restores this on resimulate() replay so replayed inputs can't overwrite the shove
|
|
437
|
-
engine.client.recordKnockback?.([payload.dir[0], 0, payload.dir[2]], payload.knockback, tps.lastHitTime)
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
if (payload.type === 'hit' && tps && payload.shooter !== engine.playerId && payload.target !== engine.playerId && payload.pos && engine.cam) {
|
|
441
|
-
const cp = engine.cam.position, d = Math.hypot(payload.pos[0] - cp.x, payload.pos[2] - cp.z)
|
|
442
|
-
if (d < 60) tps.juice?.tone(140, 0.04, Math.max(0.04, 0.16 * (1 - d / 60)), 85)
|
|
443
|
-
}
|
|
444
|
-
// A shot that hit world geometry (not a player) -- bullet-hole/scorch decal at the impact point.
|
|
445
|
-
if (payload.type === 'world_hit' && engine.decals && payload.pos) engine.decals.spawnDecal(payload.pos, payload.normal)
|
|
446
|
-
if (payload.type === 'aimpunch' && engine.cam?.punch) engine.cam.punch(payload.intensity || 0.3)
|
|
447
|
-
if (payload.type === 'death' && payload.victim) engine.players.setExpression(payload.victim, 'sorrow', 1.0)
|
|
448
|
-
if (payload.type === 'death' && tps && payload.killer === engine.playerId && payload.victim !== engine.playerId) {
|
|
449
|
-
// dedup window scales with RTT: a fixed 1500ms window double-counts a kill when 'death' lags the lethal 'hit' under reordering
|
|
450
|
-
const dedupWin = Math.max(2500, (engine.client.getRTT?.() || 0) * 2.5)
|
|
451
|
-
if (tps._killCreditVictim === payload.victim && Date.now() - (tps._killCreditAt || 0) < dedupWin) {
|
|
452
|
-
tps._killCreditVictim = null
|
|
453
|
-
if (typeof payload.streak === 'number' && payload.streak > 0) { tps.streak = payload.streak; tps.killTime = Date.now() }
|
|
454
|
-
} else creditKill(tps, payload.streak)
|
|
455
|
-
if (typeof payload.killerKills === 'number') tps.kills = payload.killerKills
|
|
456
|
-
tps.lastKillWasHeadshot = !!payload.headshot
|
|
457
|
-
tps.lastKilledPlayer = payload.killerName || 'Player'
|
|
458
|
-
}
|
|
459
|
-
if (payload.type === 'death' && tps && payload.victim === engine.playerId) tps.deathKiller = payload.killer || null
|
|
460
|
-
if (payload.type === 'respawn' && tps) { tps.respawnFadeAt = Date.now(); tps.spawnShieldUntil = Date.now() + (payload.invulnMs || 0); if (typeof payload.ammo === 'number') tps.ammo = payload.ammo; tps.reloading = false; tps.reloadEndTime = null; tps.juice?.tone(420, 0.14, 0.16, 680) }
|
|
461
|
-
if (payload.type === 'empty_click' && tps) { if (window.__funJuice) window.__funJuice.empty++; tps.juice?.tone(90, 0.08, 0.13) }
|
|
462
|
-
if (payload.type === 'hazard_damage' && tps && payload.playerId === engine.playerId) { tps.lastHitTime = Date.now(); tps.juice?.tone(150, 0.1, 0.14) }
|
|
463
|
-
if (payload.type === 'buff_applied' && tps) { tps.boost = { expiresAt: Date.now() + (payload.duration || 45) * 1000, fireRate: payload.fireRate || 1 }; tps.buffFlashAt = Date.now(); tps.juice?.tone(300, 0.18, 0.16, 600) }
|
|
464
|
-
if (payload.type === 'buff_expired' && tps) { tps.boost = null; tps.juice?.tone(440, 0.16, 0.12, 200) }
|
|
465
|
-
if (payload.type === 'reload_start' && tps) { tps.reloading = true; tps.reloadDuration = payload.duration || 2000; tps.reloadEndTime = Date.now() + tps.reloadDuration; tps.juice?.tone(280, 0.05, 0.13); const animator = engine.players?.getAnimator(engine.playerId); if (animator) animator.reload() }
|
|
466
|
-
if (payload.type === 'reload_complete' && tps) { tps.reloading = false; tps.reloadEndTime = null; tps.ammo = tps.magazineSize || 30; tps.juice?.tone(520, 0.05, 0.14) }
|
|
467
|
-
},
|
|
468
|
-
onFrame(dt, engine) {
|
|
469
|
-
const tps = engine._tps; if (!tps) return
|
|
470
|
-
if (tps.boost && Date.now() >= tps.boost.expiresAt) tps.boost = null
|
|
471
|
-
if (tps.flash && tps.flashOff && Date.now() >= tps.flashOff) { tps.flash.intensity = 0; tps.flashOff = 0 }
|
|
472
|
-
engine.players.setAiming(engine.playerId, tps.isAiming)
|
|
473
|
-
const ov = tps.overlay; if (!ov) return
|
|
474
|
-
const now = Date.now()
|
|
475
|
-
const cross = tps._elCross
|
|
476
|
-
if (cross) {
|
|
477
|
-
const scale = now - tps.lastShootTime < 130 ? 1.6 : 1
|
|
478
|
-
if (tps._lastCrossScale !== scale) { tps._lastCrossScale = scale; cross.style.transform = 'translate(-50%,-50%) scale(' + scale + ')' }
|
|
479
|
-
}
|
|
480
|
-
const hit = tps._elHit
|
|
481
|
-
if (hit) {
|
|
482
|
-
const rttPad = Math.min(120, engine.client.getRTT?.() || 0)
|
|
483
|
-
const onHs = now - tps.headshotMarkerTime < 250 + rttPad
|
|
484
|
-
const onHit = now - tps.hitMarkerTime < 150 + rttPad
|
|
485
|
-
const hitOn = (onHit || onHs) ? '1' : '0'
|
|
486
|
-
if (tps._lastHitOn !== hitOn) { tps._lastHitOn = hitOn; hit.style.opacity = hitOn }
|
|
487
|
-
const col = onHs ? '#ffcc33' : '#ffffff'
|
|
488
|
-
if (tps._lastHitCol !== col) { tps._lastHitCol = col; for (const m of tps._elHitMarks) m.style.background = col }
|
|
489
|
-
}
|
|
490
|
-
const kill = tps._elKill
|
|
491
|
-
if (kill) {
|
|
492
|
-
const onKill = now - tps.killTime < Math.min(2200, 1200 + (engine.client.getRTT?.() || 0))
|
|
493
|
-
const streak = tps.streak || 0
|
|
494
|
-
const killText = onKill ? (streak >= 4 ? 'MULTI KILL' : streak === 3 ? 'TRIPLE KILL' : streak === 2 ? 'DOUBLE KILL' : 'KILL') : ''
|
|
495
|
-
if (tps._lastKillText !== killText) { tps._lastKillText = killText; kill.textContent = killText; kill.style.opacity = onKill ? '1' : '0' }
|
|
496
|
-
}
|
|
497
|
-
const dd = tps._elDmgDealt
|
|
498
|
-
if (dd) { const on = now - (tps.dmgDealtTime || 0) < 500; const t = on ? ('+' + (tps.lastDamageDealt || 0)) : ''; if (tps._lastDdText !== t) { tps._lastDdText = t; dd.textContent = t; dd.style.opacity = on ? '1' : '0' } }
|
|
499
|
-
const dtk = tps._elDmgTaken
|
|
500
|
-
if (dtk) { const on = now - (tps.lastHitTime || 0) < 450; const t = on && tps.lastDamageTaken ? ('-' + tps.lastDamageTaken) : ''; if (tps._lastDtkText !== t) { tps._lastDtkText = t; dtk.textContent = t; dtk.style.opacity = on ? '1' : '0' } }
|
|
501
|
-
const shield = tps._elShield
|
|
502
|
-
if (shield) { const rem = (tps.spawnShieldUntil || 0) - now; const op = rem > 0 ? String(Math.min(0.6, rem / 1500 * 0.6)) : '0'; if (tps._lastShieldOp !== op) { tps._lastShieldOp = op; shield.style.opacity = op } }
|
|
503
|
-
const lp = engine.client?.state?.players?.find(p => p.id === engine.playerId)
|
|
504
|
-
const vig = tps._elVig
|
|
505
|
-
{
|
|
506
|
-
const vy = lp?.velocity?.[1] ?? 0, og = !!lp?.onGround
|
|
507
|
-
if (og && tps._wasOnGround === false && (tps._fallVy || 0) < -9 && engine.cam?.punch) engine.cam.punch(0.18)
|
|
508
|
-
tps._fallVy = og ? 0 : vy; tps._wasOnGround = og
|
|
509
|
-
}
|
|
510
|
-
if (vig) {
|
|
511
|
-
const hp = lp?.health ?? 100
|
|
512
|
-
const dmgFlash = now - (tps.lastHitTime || 0) < 220 ? 0.32 : 0
|
|
513
|
-
const lowHp = hp > 0 && hp < 30 ? 0.12 + 0.06 * Math.sin(now / 180) : 0
|
|
514
|
-
const op = String(Math.min(0.4, Math.max(dmgFlash, lowHp)))
|
|
515
|
-
if (tps._lastVigOp !== op) { tps._lastVigOp = op; vig.style.opacity = op }
|
|
516
|
-
}
|
|
517
|
-
},
|
|
518
|
-
render(ctx) {
|
|
519
|
-
const h = ctx.h; if (!h) return { position: ctx.entity.position }
|
|
520
|
-
const s = ctx.state || {}
|
|
521
|
-
// ctx.kit is threaded in by app.js's top-level import -- apps must not dynamically import (AppLoader sandbox forbids it)
|
|
522
|
-
const local = ctx.players?.find(p => p.id === ctx.engine?.playerId)
|
|
523
|
-
const hp = local?.health ?? 100
|
|
524
|
-
const tps = ctx.engine?._tps
|
|
525
|
-
const boostSec = tps?.boost ? Math.ceil((tps.boost.expiresAt - Date.now()) / 1000) : 0
|
|
526
|
-
const ammo = tps?.ammo ?? 0
|
|
527
|
-
const magazine = s.config?.magazineSize ?? 30
|
|
528
|
-
const reloading = tps?.reloading ?? false
|
|
529
|
-
const reloadDur = tps?.reloadDuration || 2000
|
|
530
|
-
const reloadProgress = reloading && tps?.reloadEndTime ? Math.min(100, Math.round((1 - (tps.reloadEndTime - Date.now()) / reloadDur) * 100)) : 0
|
|
531
|
-
const kills = tps?.kills ?? 0
|
|
532
|
-
const now = Date.now()
|
|
533
|
-
const rttPad = Math.min(120, ctx.engine?.client?.getRTT?.() || 0)
|
|
534
|
-
const hitMarkerActive = now - (tps?.hitMarkerTime || 0) < 150 + rttPad
|
|
535
|
-
const headshot = !!(tps?.headshotMarkerTime && now - tps.headshotMarkerTime < 250 + rttPad)
|
|
536
|
-
const killConfirm = now - (tps?.killTime || 0) < 1500
|
|
537
|
-
const renderGameHud = ctx.kit?.renderGameHud
|
|
538
|
-
return {
|
|
539
|
-
position: ctx.entity.position,
|
|
540
|
-
custom: { game: s.map, mode: s.mode, kills },
|
|
541
|
-
ui: renderGameHud ? renderGameHud(h, { hp, ammo, magazine, reloading, reloadProgress, boostSec, kills, hitMarkerActive, headshot, killConfirm }) : null
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
7
|
+
server: tpsGameServer,
|
|
8
|
+
client: tpsGameClient
|
|
545
9
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { findSpawnPoints, getAvailableSpawnPoint, handleFire, loadScoreboard, flushScoreboard, persistPlayerStat } from './server.js'
|
|
2
|
+
import { collectSpawnPoints } from '../spawn-point/index.js'
|
|
3
|
+
import { POWERUP_DEFS, POWERUP_RESPAWN_MS, POWERUP_PICKUP_RADIUS, EMOTE_CLIPS, spawnPowerup } from './shared.js'
|
|
4
|
+
|
|
5
|
+
export const tpsGameServer = {
|
|
6
|
+
async setup(ctx) {
|
|
7
|
+
ctx.state.map = 'schwust'
|
|
8
|
+
ctx.state.mode = 'ffa'
|
|
9
|
+
ctx.state.config = { respawnTime: 1.5, health: 100, damagePerHit: 20, headshotMultiplier: 2.5, headshotZone: 0.7, hitKnockback: 4, shootKnockback: 2, magazineSize: 30, reloadTime: 2000, spawnInvulnMs: 1500 }
|
|
10
|
+
ctx.state.invuln = new Map()
|
|
11
|
+
// Placed spawn-point entities (apps/spawn-point) take priority over the raycast grid --
|
|
12
|
+
// a maker who drops markers gets exactly those; the grid is only the no-markers-placed
|
|
13
|
+
// fallback so existing worlds without markers keep working unchanged.
|
|
14
|
+
const placedSpawns = collectSpawnPoints(ctx)
|
|
15
|
+
ctx.state.spawnPoints = placedSpawns.length > 0 ? placedSpawns : findSpawnPoints(ctx)
|
|
16
|
+
ctx.state.playerStats = new Map()
|
|
17
|
+
// Cumulative session/across-restart scoreboard, keyed by durable player name -- see server.js
|
|
18
|
+
// loadScoreboard/persistPlayerStat. Awaited here (AppRuntime awaits server.setup) so it is fully
|
|
19
|
+
// populated before the FIRST player_join can look a name up.
|
|
20
|
+
await loadScoreboard(ctx)
|
|
21
|
+
// Register the debounced scoreboard write's flush with the engine's graceful-shutdown registry
|
|
22
|
+
// (ctx.onShutdown, src/apps/AppContext.js/AppRuntime.js) so a SIGINT/SIGTERM within the 500ms
|
|
23
|
+
// debounce window (see scheduleScoreboardPersist in server.js) doesn't silently drop the last
|
|
24
|
+
// burst of kill/death stat changes -- mirrors ctx.placedModelStorage.flush()'s own shutdown wiring.
|
|
25
|
+
ctx.onShutdown(() => flushScoreboard(ctx))
|
|
26
|
+
ctx.state.respawning = new Map()
|
|
27
|
+
ctx.state.buffs = new Map()
|
|
28
|
+
ctx.state.ammo = new Map()
|
|
29
|
+
ctx.state.reloading = new Map()
|
|
30
|
+
ctx.state.lastEmoteAt = new Map()
|
|
31
|
+
ctx.state.started = Date.now()
|
|
32
|
+
ctx.state.gameTime = 0
|
|
33
|
+
ctx.state.fallTimers = new Map()
|
|
34
|
+
ctx.state.killStreaks = new Map()
|
|
35
|
+
ctx.state.powerups = new Map()
|
|
36
|
+
const sps = ctx.state.spawnPoints
|
|
37
|
+
const picks = (sps && sps.length >= POWERUP_DEFS.length)
|
|
38
|
+
? POWERUP_DEFS.map((_, i) => sps[Math.floor((i + 1) * sps.length / (POWERUP_DEFS.length + 1))])
|
|
39
|
+
: POWERUP_DEFS.map((_, i) => [i * 8 - 8, 3, 0])
|
|
40
|
+
POWERUP_DEFS.forEach((def, i) => {
|
|
41
|
+
const p = picks[i], pos = [p[0], p[1] + 0.6, p[2]], id = `powerup_${def.type}`
|
|
42
|
+
ctx.state.powerups.set(id, { def, position: pos, active: true, respawnAt: 0 })
|
|
43
|
+
spawnPowerup(ctx, id, def, pos)
|
|
44
|
+
})
|
|
45
|
+
ctx.bus.on('powerup.collected', (event) => {
|
|
46
|
+
const d = event.data
|
|
47
|
+
ctx.state.buffs.set(d.playerId, { expiresAt: Date.now() + d.duration * 1000, speed: d.speedMultiplier, fireRate: d.fireRateMultiplier, damage: d.damageMultiplier })
|
|
48
|
+
ctx.players.send(d.playerId, { type: 'buff_applied', duration: d.duration, speed: d.speedMultiplier, fireRate: d.fireRateMultiplier, damage: d.damageMultiplier })
|
|
49
|
+
})
|
|
50
|
+
console.log(`[tps-game] ${ctx.state.spawnPoints.length} spawn points validated`)
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
update(ctx, dt) {
|
|
54
|
+
ctx.state.gameTime = (Date.now() - ctx.state.started) / 1000
|
|
55
|
+
const now = Date.now()
|
|
56
|
+
// Defensive re-init: setup() constructs ctx.state.buffs as a real Map. The two real root causes
|
|
57
|
+
// that could hand back a non-Map here are both fixed upstream now: (1) the init-order race, where
|
|
58
|
+
// update() could run before setup()'s async loadScoreboard await resolves -- see AppRuntime.js's
|
|
59
|
+
// _pendingSetupIds skip in _rebuildUpdateList/_rebuildCollisionList; (2) a Map silently downgrading
|
|
60
|
+
// to a plain object across any restoreGameState/WorldPersistence round-trip, since a naive
|
|
61
|
+
// JSON.parse(JSON.stringify(...)) has no Map wire type -- fixed via AppRuntime.js's tagged
|
|
62
|
+
// cloneAppState (Map/Set-preserving replacer/reviver), used for entity._appState (ctx.state)
|
|
63
|
+
// specifically. This guard stays as cheap, top-of-update defense-in-depth against any future
|
|
64
|
+
// write path this loop hasn't been audited against yet, not because either known cause is still open.
|
|
65
|
+
if (!(ctx.state.buffs instanceof Map)) ctx.state.buffs = new Map()
|
|
66
|
+
for (const [pid, buff] of ctx.state.buffs) {
|
|
67
|
+
if (now >= buff.expiresAt) { ctx.state.buffs.delete(pid); ctx.players.send(pid, { type: 'buff_expired' }) }
|
|
68
|
+
else { const player = ctx.players.getById(pid); if (player?.state) player.state.health = Math.min(ctx.state.config.health, (player.state.health ?? ctx.state.config.health) + (ctx.state.config.health / 10) * dt) }
|
|
69
|
+
}
|
|
70
|
+
const allPlayers = ctx.players.getAll()
|
|
71
|
+
for (const player of allPlayers) {
|
|
72
|
+
if (!player.state || ctx.state.respawning.has(player.id)) continue
|
|
73
|
+
if ((player.state.health ?? ctx.state.config.health) <= 0) continue
|
|
74
|
+
const y = player.state.position?.[1] ?? 0
|
|
75
|
+
if (y < -20) {
|
|
76
|
+
const t = (ctx.state.fallTimers.get(player.id) || 0) + dt
|
|
77
|
+
ctx.state.fallTimers.set(player.id, t)
|
|
78
|
+
if (t >= 0.5) { player.state.health = 0; ctx.state.respawning.set(player.id, { respawnAt: now + ctx.state.config.respawnTime * 1000, killer: null }); ctx.network.broadcast({ type: 'death', victim: player.id, killer: null, cause: 'fall' }); ctx.state.fallTimers.delete(player.id) }
|
|
79
|
+
} else { ctx.state.fallTimers.delete(player.id) }
|
|
80
|
+
}
|
|
81
|
+
// Same defensive re-init as ctx.state.buffs above (init-order race / Map->plain-object downgrade
|
|
82
|
+
// across a restoreGameState/WorldPersistence round-trip) -- powerups hit the identical hazard since
|
|
83
|
+
// it is also a Map constructed once in setup() with no per-tick type guard until now.
|
|
84
|
+
if (!(ctx.state.powerups instanceof Map)) ctx.state.powerups = new Map()
|
|
85
|
+
{
|
|
86
|
+
for (const [id, pu] of ctx.state.powerups) {
|
|
87
|
+
if (pu.active) {
|
|
88
|
+
for (const player of allPlayers) {
|
|
89
|
+
if (!player.state || ctx.state.respawning.has(player.id)) continue
|
|
90
|
+
if ((player.state.health ?? ctx.state.config.health) <= 0) continue
|
|
91
|
+
const pp = player.state.position; if (!pp) continue
|
|
92
|
+
const dx = pp[0] - pu.position[0], dy = pp[1] - pu.position[1], dz = pp[2] - pu.position[2]
|
|
93
|
+
if (dx * dx + dy * dy + dz * dz <= POWERUP_PICKUP_RADIUS * POWERUP_PICKUP_RADIUS) {
|
|
94
|
+
ctx.bus.emit('powerup.collected', { playerId: player.id, duration: pu.def.buff.duration, speedMultiplier: pu.def.buff.speedMultiplier, fireRateMultiplier: pu.def.buff.fireRateMultiplier, damageMultiplier: pu.def.buff.damageMultiplier })
|
|
95
|
+
ctx.world.destroy(id)
|
|
96
|
+
pu.active = false; pu.respawnAt = now + POWERUP_RESPAWN_MS
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else if (now >= pu.respawnAt) {
|
|
101
|
+
spawnPowerup(ctx, id, pu.def, pu.position); pu.active = true
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const [pid, data] of ctx.state.respawning) {
|
|
106
|
+
if (now < data.respawnAt) continue
|
|
107
|
+
const sp = getAvailableSpawnPoint(ctx, ctx.state.spawnPoints)
|
|
108
|
+
const player = ctx.players.getById(pid)
|
|
109
|
+
if (player?.state) { player.state.health = ctx.state.config.health; player.state.velocity = [0, 0, 0]; ctx.players.setPosition(pid, sp) }
|
|
110
|
+
ctx.state.invuln.set(pid, now + (ctx.state.config.spawnInvulnMs || 0))
|
|
111
|
+
// respawn must reset ammo/reload same as player_join, else stale magazine silently rejects every shot client thinks it has
|
|
112
|
+
ctx.state.ammo.set(pid, ctx.state.config.magazineSize)
|
|
113
|
+
ctx.state.reloading.delete(pid)
|
|
114
|
+
ctx.players.send(pid, { type: 'respawn', position: sp, health: ctx.state.config.health, ammo: ctx.state.config.magazineSize, invulnMs: ctx.state.config.spawnInvulnMs })
|
|
115
|
+
ctx.state.respawning.delete(pid)
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
onMessage(ctx, msg) {
|
|
120
|
+
if (!msg) return
|
|
121
|
+
if (msg.type === 'player_join') {
|
|
122
|
+
const p = ctx.players.getById(msg.playerId)
|
|
123
|
+
// must not force health on reconnect: RECONNECT_ACK already restored it, forcing max would res a mid-blip death
|
|
124
|
+
if (p?.state && !msg.reconnected) p.state.health = ctx.state.config.health
|
|
125
|
+
if (!msg.reconnected || !ctx.state.playerStats.has(msg.playerId)) {
|
|
126
|
+
// Restore cumulative kills/deaths/damage from the durable by-name scoreboard (see server.js
|
|
127
|
+
// loadScoreboard/persistPlayerStat) if this player's name has a saved record -- a fresh Map
|
|
128
|
+
// entry every join/reconnect used to silently reset the live in-memory stats to zero even
|
|
129
|
+
// though the durable record on disk still had the player's real cumulative totals.
|
|
130
|
+
const name = p?.name || `Player ${msg.playerId}`
|
|
131
|
+
const saved = ctx.state.scoreboardByName?.[name]
|
|
132
|
+
ctx.state.playerStats.set(msg.playerId, saved ? { kills: saved.kills || 0, deaths: saved.deaths || 0, damage: saved.damage || 0 } : { kills: 0, deaths: 0, damage: 0 })
|
|
133
|
+
}
|
|
134
|
+
ctx.state.ammo.set(msg.playerId, ctx.state.config.magazineSize)
|
|
135
|
+
ctx.state.reloading.delete(msg.playerId)
|
|
136
|
+
}
|
|
137
|
+
if (msg.type === 'player_leave') {
|
|
138
|
+
// Final mirror-and-persist BEFORE dropping the in-memory entry -- covers the case where the
|
|
139
|
+
// last stat change since the previous debounce fired (e.g. a damage tick from the shot that
|
|
140
|
+
// killed the leaving player) hasn't hit disk yet. The durable by-name record is what survives;
|
|
141
|
+
// the in-memory playerStats Map is keyed by this ephemeral playerId and is safe to drop, since
|
|
142
|
+
// a future rejoin re-seeds from ctx.state.scoreboardByName (by name) on player_join above.
|
|
143
|
+
persistPlayerStat(ctx, msg.playerId)
|
|
144
|
+
ctx.state.playerStats.delete(msg.playerId); ctx.state.respawning.delete(msg.playerId)
|
|
145
|
+
ctx.state.fallTimers.delete(msg.playerId); ctx.state.ammo.delete(msg.playerId); ctx.state.reloading.delete(msg.playerId); ctx.state.invuln.delete(msg.playerId)
|
|
146
|
+
}
|
|
147
|
+
if (msg.type === 'reload') {
|
|
148
|
+
const playerId = msg.senderId || msg.playerId
|
|
149
|
+
if (ctx.state.reloading.has(playerId) || (ctx.state.ammo.get(playerId) ?? 0) >= ctx.state.config.magazineSize) return
|
|
150
|
+
ctx.state.reloading.set(playerId, { startTime: Date.now() })
|
|
151
|
+
ctx.players.send(playerId, { type: 'reload_start', duration: ctx.state.config.reloadTime })
|
|
152
|
+
setTimeout(() => { ctx.state.ammo.set(playerId, ctx.state.config.magazineSize); ctx.state.reloading.delete(playerId); ctx.players.send(playerId, { type: 'reload_complete' }) }, ctx.state.config.reloadTime)
|
|
153
|
+
}
|
|
154
|
+
if (msg.type === 'emote') {
|
|
155
|
+
// Server-authoritative allowlist: never trust a client-supplied clip name directly into
|
|
156
|
+
// playAnimation (an arbitrary string reaching the animation library lookup is low-risk here
|
|
157
|
+
// since it only no-ops on a miss, but an explicit allowlist is the correct discipline for any
|
|
158
|
+
// client-triggered broadcast -- matches roadmap #78's own 'networked emote codes' framing,
|
|
159
|
+
// a CODE the client sends, not a free-text clip name). Rate-limited per player (reuses the
|
|
160
|
+
// same reload-style timestamp-gate pattern as the fire/reload handlers above) so a client
|
|
161
|
+
// can't spam a broadcast to every other connected player.
|
|
162
|
+
const playerId = msg.senderId || msg.playerId
|
|
163
|
+
const now = Date.now()
|
|
164
|
+
const lastEmote = ctx.state.lastEmoteAt.get(playerId) || 0
|
|
165
|
+
if (now - lastEmote < 800) return
|
|
166
|
+
if (!EMOTE_CLIPS.has(msg.code)) return
|
|
167
|
+
ctx.state.lastEmoteAt.set(playerId, now)
|
|
168
|
+
ctx.players.playAnimation(playerId, EMOTE_CLIPS.get(msg.code), { loop: false })
|
|
169
|
+
}
|
|
170
|
+
if (msg.type === 'fire') {
|
|
171
|
+
const shooterId = msg.senderId || msg.shooterId
|
|
172
|
+
if (ctx.state.reloading.has(shooterId)) return
|
|
173
|
+
const ammo = ctx.state.ammo.get(shooterId) ?? 0
|
|
174
|
+
if (ammo <= 0) { ctx.players.send(shooterId, { type: 'empty_click' }); return }
|
|
175
|
+
ctx.state.ammo.set(shooterId, ammo - 1)
|
|
176
|
+
const shooter = ctx.players.getById(shooterId)
|
|
177
|
+
const pos = shooter?.state?.position || [0, 0, 0]
|
|
178
|
+
const origin = [pos[0], pos[1] + 0.9, pos[2]]
|
|
179
|
+
// msg.clientTime is expressed in ESTIMATED SERVER CLOCK time (BaseClient.sendFire adds the
|
|
180
|
+
// client's NTP-style clock offset before sending), so Date.now()-msg.clientTime here is a real
|
|
181
|
+
// one-way client->server delay estimate, not the old raw-clock-skew-conflated value.
|
|
182
|
+
const latencyMs = msg.clientTime ? Math.min(600, Math.max(0, Date.now() - msg.clientTime)) : 0
|
|
183
|
+
const fireData = { shooterId, origin, direction: msg.direction, latencyMs }
|
|
184
|
+
ctx.bus.emit('combat.fire', fireData)
|
|
185
|
+
if (shooter?.state) { shooter.state.velocity[0] -= msg.direction[0] * ctx.state.config.shootKnockback; shooter.state.velocity[2] -= msg.direction[2] * ctx.state.config.shootKnockback }
|
|
186
|
+
ctx.players.send(shooterId, { type: 'aimpunch', intensity: 0.3 })
|
|
187
|
+
handleFire(ctx, fireData)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Constants and pure helpers shared between apps/tps-game/index.js's server and client app halves.
|
|
2
|
+
|
|
3
|
+
export const POWERUP_DEFS = [
|
|
4
|
+
{ type: 'damage', color: 0xff3344, emissive: 0xaa0000, buff: { duration: 20, speedMultiplier: 1, fireRateMultiplier: 1, damageMultiplier: 2 } },
|
|
5
|
+
{ type: 'speed', color: 0x33aaff, emissive: 0x0044aa, buff: { duration: 20, speedMultiplier: 1.5, fireRateMultiplier: 1, damageMultiplier: 1 } },
|
|
6
|
+
{ type: 'rapid', color: 0xffcc33, emissive: 0xaa6600, buff: { duration: 20, speedMultiplier: 1, fireRateMultiplier: 2, damageMultiplier: 1 } },
|
|
7
|
+
]
|
|
8
|
+
export const POWERUP_RESPAWN_MS = 15000
|
|
9
|
+
export const POWERUP_PICKUP_RADIUS = 1.7
|
|
10
|
+
|
|
11
|
+
// Emote wheel (roadmap #78): a short networked CODE (not a free-text clip name -- server allowlist
|
|
12
|
+
// in server.js), mapped to a real clip confirmed present in client/anim-lib.glb (109 real clips inspected
|
|
13
|
+
// live via gltf-transform, not guessed). Order here is the wheel's slot 1..8 (client/hud/EmoteWheel.js
|
|
14
|
+
// lays slots out clockwise from the top in this same array order).
|
|
15
|
+
export const EMOTE_CLIPS = new Map([
|
|
16
|
+
['wave', 'Bow'],
|
|
17
|
+
['dance', 'DanceLoop'],
|
|
18
|
+
['nod', 'HeadNod'],
|
|
19
|
+
['victory', 'Victory'],
|
|
20
|
+
['meditate', 'Meditate'],
|
|
21
|
+
['jumpingjacks', 'JumpingJacks'],
|
|
22
|
+
['confused', 'Confused'],
|
|
23
|
+
['sit', 'SittingEnter'],
|
|
24
|
+
])
|
|
25
|
+
export const EMOTE_WHEEL_SLOTS = [
|
|
26
|
+
{ code: 'wave', label: 'Bow' },
|
|
27
|
+
{ code: 'dance', label: 'Dance' },
|
|
28
|
+
{ code: 'nod', label: 'Nod' },
|
|
29
|
+
{ code: 'victory', label: 'Victory' },
|
|
30
|
+
{ code: 'meditate', label: 'Meditate' },
|
|
31
|
+
{ code: 'jumpingjacks', label: 'Jumping Jacks' },
|
|
32
|
+
{ code: 'confused', label: 'Confused' },
|
|
33
|
+
{ code: 'sit', label: 'Sit' },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
export function spawnPowerup(ctx, id, def, position) {
|
|
37
|
+
ctx.world.spawn(id, {
|
|
38
|
+
position: [...position], scale: [0.55, 0.55, 0.55],
|
|
39
|
+
custom: { mesh: 'box', powerup: def.type, color: def.color, emissive: def.emissive, emissiveIntensity: 0.7, light: def.color, lightIntensity: 0.9, lightRange: 6, spin: 1.6, hover: 0.35 }
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// mirrors server.js handleFire's hit geometry (capsule +0.9, radius 0.6) for client-side prediction; server stays authoritative
|
|
44
|
+
export function predictHit(origin, dir, players, selfId, headshotZone) {
|
|
45
|
+
if (!origin || !dir || !players) return null
|
|
46
|
+
for (const p of players) {
|
|
47
|
+
if (!p || p.id === selfId || !p.position) continue
|
|
48
|
+
if ((p.health ?? 100) <= 0) continue
|
|
49
|
+
const tp = p.position
|
|
50
|
+
const toX = tp[0] - origin[0], toY = tp[1] + 0.9 - origin[1], toZ = tp[2] - origin[2]
|
|
51
|
+
const dot = toX * dir[0] + toY * dir[1] + toZ * dir[2]
|
|
52
|
+
if (dot < 0 || dot > 1000) continue
|
|
53
|
+
const px = origin[0] + dir[0] * dot, py = origin[1] + dir[1] * dot, pz = origin[2] + dir[2] * dot
|
|
54
|
+
const ddx = px - tp[0], ddy = py - (tp[1] + 0.9), ddz = pz - tp[2]
|
|
55
|
+
const d2 = ddx * ddx + ddy * ddy + ddz * ddz
|
|
56
|
+
if (d2 > 0.36) continue
|
|
57
|
+
return { headshot: ((py - tp[1]) / 1.8) >= (headshotZone ?? 0.7) }
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|