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.
@@ -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
+ }
@@ -1,545 +1,9 @@
1
- import { findSpawnPoints, getAvailableSpawnPoint, handleFire, loadScoreboard, flushScoreboard, persistPlayerStat } from './server.js'
2
- import { collectSpawnPoints } from '../spawn-point/index.js'
1
+ import { tpsGameServer } from './server-app.js'
2
+ import { tpsGameClient } from './client-app.js'
3
3
 
4
- const POWERUP_DEFS = [
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
- async setup(ctx) {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.645",
3
+ "version": "0.1.646",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [