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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|