spoint 0.1.644 → 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.
@@ -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
  }