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.
- package/apps/_lib/destructible.js +5 -71
- package/apps/_lib/destructibleSpec.js +72 -0
- package/apps/tps-game/client-app.js +296 -0
- package/apps/tps-game/index.js +5 -541
- package/apps/tps-game/server-app.js +190 -0
- package/apps/tps-game/shared.js +60 -0
- package/package.json +1 -1
|
@@ -0,0 +1,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
|
+
}
|