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.
@@ -65,75 +65,9 @@
65
65
  // Call destructible.tick(dt) once per server tick from the owning app's update(ctx, dt) -- this object
66
66
  // owns its own debris-lifetime/respawn/impact-scan timers, it does not register a runtime-level watch.
67
67
 
68
- const _PARK_OFFSET = [0, -5000, 0] // far enough below any reasonable playfield that stray collisions are impossible
69
-
70
- function _isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v) }
71
-
72
- function _validateSpec(spec) {
73
- if (spec !== null && typeof spec !== 'object') throw new TypeError('[destructible] spec must be an object')
74
- const s = spec || {}
75
- if (s.health != null && (typeof s.health !== 'number' || !Number.isFinite(s.health) || s.health <= 0)) {
76
- throw new TypeError('[destructible] health must be a positive finite number')
77
- }
78
- if (s.impactThreshold != null && (typeof s.impactThreshold !== 'number' || !Number.isFinite(s.impactThreshold) || s.impactThreshold < 0)) {
79
- throw new TypeError('[destructible] impactThreshold must be a non-negative finite number')
80
- }
81
- if (s.impactRadius != null && (typeof s.impactRadius !== 'number' || !Number.isFinite(s.impactRadius) || s.impactRadius <= 0)) {
82
- throw new TypeError('[destructible] impactRadius must be a positive finite number')
83
- }
84
- if (s.debrisCount != null && (!Number.isInteger(s.debrisCount) || s.debrisCount < 1)) {
85
- throw new TypeError('[destructible] debrisCount must be a positive integer')
86
- }
87
- if (s.debrisLifetime != null && (typeof s.debrisLifetime !== 'number' || !Number.isFinite(s.debrisLifetime) || s.debrisLifetime < 0)) {
88
- throw new TypeError('[destructible] debrisLifetime must be a non-negative finite number (seconds); 0 = never despawn')
89
- }
90
- if (s.debrisSettleGrace != null && (typeof s.debrisSettleGrace !== 'number' || !Number.isFinite(s.debrisSettleGrace) || s.debrisSettleGrace < 0)) {
91
- throw new TypeError('[destructible] debrisSettleGrace must be a non-negative finite number (seconds)')
92
- }
93
- if (s.debrisFreezeAfter != null && (typeof s.debrisFreezeAfter !== 'number' || !Number.isFinite(s.debrisFreezeAfter) || s.debrisFreezeAfter < 0)) {
94
- throw new TypeError('[destructible] debrisFreezeAfter must be a non-negative finite number (seconds); 0 = disable force-freeze')
95
- }
96
- if (s.respawnDelay != null && (typeof s.respawnDelay !== 'number' || !Number.isFinite(s.respawnDelay) || s.respawnDelay < 0)) {
97
- throw new TypeError('[destructible] respawnDelay must be a non-negative finite number (seconds); 0 = never respawn')
98
- }
99
- if (s.debrisImpulsePattern != null && typeof s.debrisImpulsePattern !== 'function' && !['outward', 'outward-up', 'up'].includes(s.debrisImpulsePattern)) {
100
- throw new TypeError('[destructible] debrisImpulsePattern must be "outward", "outward-up", "up", or a function(i, n, rng) -> [x,y,z]')
101
- }
102
- if (s.debrisShape != null && !_isPlainObject(s.debrisShape)) throw new TypeError('[destructible] debrisShape must be a plain object')
103
- if (s.fracturedAsset != null && typeof s.fracturedAsset !== 'string') throw new TypeError('[destructible] fracturedAsset must be a string path to a scripts/fracture-glb.mjs-baked GLB')
104
- if (s.fracturedPieceCount != null && (!Number.isInteger(s.fracturedPieceCount) || s.fracturedPieceCount < 1)) {
105
- throw new TypeError('[destructible] fracturedPieceCount must be a positive integer (the number of baked pieces in fracturedAsset)')
106
- }
107
- if (s.fracturedAsset != null && s.fracturedPieceCount == null) {
108
- throw new TypeError('[destructible] fracturedPieceCount is required when fracturedAsset is set (read it from the baked <asset>.pieces.json sidecar\'s pieceCount field)')
109
- }
110
- if (s.onDestroyed != null && typeof s.onDestroyed !== 'function') throw new TypeError('[destructible] onDestroyed must be a function')
111
- if (s.onRespawn != null && typeof s.onRespawn !== 'function') throw new TypeError('[destructible] onRespawn must be a function')
112
- }
113
-
114
- // cosmetic launch-direction jitter only, not gameplay-critical -- plain Math.random() is fine here.
115
- function _jitter(spread) { return (Math.random() * 2 - 1) * spread }
68
+ import { validateDestructibleSpec, resolveDebrisImpulsePattern, jitter } from './destructibleSpec.js'
116
69
 
117
- function _resolveImpulsePattern(pattern) {
118
- if (typeof pattern === 'function') return pattern
119
- if (pattern === 'up') {
120
- return () => [0, 6 + Math.random() * 3, 0]
121
- }
122
- if (pattern === 'outward') {
123
- return (i, n) => {
124
- const angle = (i / n) * Math.PI * 2 + _jitter(0.3)
125
- const mag = 3 + Math.random() * 2
126
- return [Math.cos(angle) * mag, 0, Math.sin(angle) * mag]
127
- }
128
- }
129
- // 'outward-up' (default): radial spread around the object plus a strong upward component,
130
- // matching the prototype's "outward+up launch impulses on impact" behavior.
131
- return (i, n) => {
132
- const angle = (i / n) * Math.PI * 2 + _jitter(0.4)
133
- const mag = 2.5 + Math.random() * 2.5
134
- return [Math.cos(angle) * mag, 5 + Math.random() * 4, Math.sin(angle) * mag]
135
- }
136
- }
70
+ const _PARK_OFFSET = [0, -5000, 0] // far enough below any reasonable playfield that stray collisions are impossible
137
71
 
138
72
  // spec = {
139
73
  // health?: number -- total damage capacity before destruction (default 100)
@@ -181,7 +115,7 @@ function _resolveImpulsePattern(pattern) {
181
115
  // onRespawn?: (ctx) => void -- fires once the intact object respawns
182
116
  // }
183
117
  export function createDestructible(spec = {}, appCtx = null) {
184
- _validateSpec(spec)
118
+ validateDestructibleSpec(spec)
185
119
  if (!appCtx) throw new TypeError('[destructible] appCtx is required')
186
120
 
187
121
  const health = spec.health ?? 100
@@ -192,7 +126,7 @@ export function createDestructible(spec = {}, appCtx = null) {
192
126
  const debrisSettleGrace = spec.debrisSettleGrace ?? 0.5
193
127
  const debrisFreezeAfter = spec.debrisFreezeAfter ?? 3
194
128
  const respawnDelay = spec.respawnDelay ?? 0
195
- const impulseFn = _resolveImpulsePattern(spec.debrisImpulsePattern ?? 'outward-up')
129
+ const impulseFn = resolveDebrisImpulsePattern(spec.debrisImpulsePattern ?? 'outward-up')
196
130
 
197
131
  // captured once at build time -- the intact object's true home/look, independent of later parking.
198
132
  const _homePosition = [...appCtx.entity.position]
@@ -359,7 +293,7 @@ export function createDestructible(spec = {}, appCtx = null) {
359
293
  const ids = []
360
294
  for (let i = 0; i < debrisCount; i++) {
361
295
  // scatter pieces slightly within the intact object's footprint so they don't all spawn co-located
362
- const jx = _jitter(hx * 0.5), jy = _jitter(hy * 0.5), jz = _jitter(hz * 0.5)
296
+ const jx = jitter(hx * 0.5), jy = jitter(hy * 0.5), jz = jitter(hz * 0.5)
363
297
  const id = _acquirePoolPiece(
364
298
  [px + jx, py + jy, pz + jz],
365
299
  appCtx.entity.rotation,
@@ -0,0 +1,72 @@
1
+ // Pure spec-validation and launch-impulse-pattern helpers for createDestructible (destructible.js) --
2
+ // split out because they carry no closure state and no dependency on appCtx/the engine, unlike every
3
+ // other function in destructible.js which closes over the per-instance pool/timer state.
4
+
5
+ function _isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v) }
6
+
7
+ export function validateDestructibleSpec(spec) {
8
+ if (spec !== null && typeof spec !== 'object') throw new TypeError('[destructible] spec must be an object')
9
+ const s = spec || {}
10
+ if (s.health != null && (typeof s.health !== 'number' || !Number.isFinite(s.health) || s.health <= 0)) {
11
+ throw new TypeError('[destructible] health must be a positive finite number')
12
+ }
13
+ if (s.impactThreshold != null && (typeof s.impactThreshold !== 'number' || !Number.isFinite(s.impactThreshold) || s.impactThreshold < 0)) {
14
+ throw new TypeError('[destructible] impactThreshold must be a non-negative finite number')
15
+ }
16
+ if (s.impactRadius != null && (typeof s.impactRadius !== 'number' || !Number.isFinite(s.impactRadius) || s.impactRadius <= 0)) {
17
+ throw new TypeError('[destructible] impactRadius must be a positive finite number')
18
+ }
19
+ if (s.debrisCount != null && (!Number.isInteger(s.debrisCount) || s.debrisCount < 1)) {
20
+ throw new TypeError('[destructible] debrisCount must be a positive integer')
21
+ }
22
+ if (s.debrisLifetime != null && (typeof s.debrisLifetime !== 'number' || !Number.isFinite(s.debrisLifetime) || s.debrisLifetime < 0)) {
23
+ throw new TypeError('[destructible] debrisLifetime must be a non-negative finite number (seconds); 0 = never despawn')
24
+ }
25
+ if (s.debrisSettleGrace != null && (typeof s.debrisSettleGrace !== 'number' || !Number.isFinite(s.debrisSettleGrace) || s.debrisSettleGrace < 0)) {
26
+ throw new TypeError('[destructible] debrisSettleGrace must be a non-negative finite number (seconds)')
27
+ }
28
+ if (s.debrisFreezeAfter != null && (typeof s.debrisFreezeAfter !== 'number' || !Number.isFinite(s.debrisFreezeAfter) || s.debrisFreezeAfter < 0)) {
29
+ throw new TypeError('[destructible] debrisFreezeAfter must be a non-negative finite number (seconds); 0 = disable force-freeze')
30
+ }
31
+ if (s.respawnDelay != null && (typeof s.respawnDelay !== 'number' || !Number.isFinite(s.respawnDelay) || s.respawnDelay < 0)) {
32
+ throw new TypeError('[destructible] respawnDelay must be a non-negative finite number (seconds); 0 = never respawn')
33
+ }
34
+ if (s.debrisImpulsePattern != null && typeof s.debrisImpulsePattern !== 'function' && !['outward', 'outward-up', 'up'].includes(s.debrisImpulsePattern)) {
35
+ throw new TypeError('[destructible] debrisImpulsePattern must be "outward", "outward-up", "up", or a function(i, n, rng) -> [x,y,z]')
36
+ }
37
+ if (s.debrisShape != null && !_isPlainObject(s.debrisShape)) throw new TypeError('[destructible] debrisShape must be a plain object')
38
+ if (s.fracturedAsset != null && typeof s.fracturedAsset !== 'string') throw new TypeError('[destructible] fracturedAsset must be a string path to a scripts/fracture-glb.mjs-baked GLB')
39
+ if (s.fracturedPieceCount != null && (!Number.isInteger(s.fracturedPieceCount) || s.fracturedPieceCount < 1)) {
40
+ throw new TypeError('[destructible] fracturedPieceCount must be a positive integer (the number of baked pieces in fracturedAsset)')
41
+ }
42
+ if (s.fracturedAsset != null && s.fracturedPieceCount == null) {
43
+ throw new TypeError('[destructible] fracturedPieceCount is required when fracturedAsset is set (read it from the baked <asset>.pieces.json sidecar\'s pieceCount field)')
44
+ }
45
+ if (s.onDestroyed != null && typeof s.onDestroyed !== 'function') throw new TypeError('[destructible] onDestroyed must be a function')
46
+ if (s.onRespawn != null && typeof s.onRespawn !== 'function') throw new TypeError('[destructible] onRespawn must be a function')
47
+ }
48
+
49
+ // cosmetic launch-direction jitter only, not gameplay-critical -- plain Math.random() is fine here.
50
+ // Exported: destructible.js's own _spawnDebris also uses this for its per-piece scatter offset.
51
+ export function jitter(spread) { return (Math.random() * 2 - 1) * spread }
52
+
53
+ export function resolveDebrisImpulsePattern(pattern) {
54
+ if (typeof pattern === 'function') return pattern
55
+ if (pattern === 'up') {
56
+ return () => [0, 6 + Math.random() * 3, 0]
57
+ }
58
+ if (pattern === 'outward') {
59
+ return (i, n) => {
60
+ const angle = (i / n) * Math.PI * 2 + jitter(0.3)
61
+ const mag = 3 + Math.random() * 2
62
+ return [Math.cos(angle) * mag, 0, Math.sin(angle) * mag]
63
+ }
64
+ }
65
+ // 'outward-up' (default): radial spread around the object plus a strong upward component,
66
+ // matching the prototype's "outward+up launch impulses on impact" behavior.
67
+ return (i, n) => {
68
+ const angle = (i / n) * Math.PI * 2 + jitter(0.4)
69
+ const mag = 2.5 + Math.random() * 2.5
70
+ return [Math.cos(angle) * mag, 5 + Math.random() * 4, Math.sin(angle) * mag]
71
+ }
72
+ }
@@ -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
+ }