mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-21 16:18:33 +02:00
fix: restore game focus and ship local 3d
This commit is contained in:
@@ -88,6 +88,9 @@
|
||||
<script src="js/render/packRegistry.js"></script>
|
||||
<script src="js/render/assetPacks.js"></script>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script type="importmap">
|
||||
{"imports":{"three":"/vendor/three/0.160.0/three.module.min.js"}}
|
||||
</script>
|
||||
<script src="js/render/renderModes.js"></script>
|
||||
<script src="js/render/charCustomize.js"></script>
|
||||
<script src="js/render/avatarVisuals.js"></script>
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var RK = root.RK = root.RK || {};
|
||||
RK.localRenderersEnabled = function () {
|
||||
return !!(root.WOWNGEON_RUNTIME && root.WOWNGEON_RUNTIME.rendererLocalEnabled === true);
|
||||
};
|
||||
RK.externalRenderersEnabled = function () {
|
||||
return !!(root.WOWNGEON_RUNTIME && root.WOWNGEON_RUNTIME.rendererCdnEnabled === true);
|
||||
};
|
||||
RK.isModeRuntimeAvailable = function (id) {
|
||||
if (!RK.modeMeta || !RK.modeMeta(id)) return false;
|
||||
if (id === '3d') {
|
||||
return !!(RK.THREE && RK.THREE.THREE) || RK.externalRenderersEnabled();
|
||||
return !!(RK.THREE && RK.THREE.THREE)
|
||||
|| RK.localRenderersEnabled()
|
||||
|| RK.externalRenderersEnabled();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -116,20 +121,46 @@
|
||||
|
||||
var threeLoading = false, threeCbs = [];
|
||||
RK.threeReady = function () { return !!(RK.THREE && RK.THREE.THREE); };
|
||||
RK.threeModuleSources = function () {
|
||||
if (RK.localRenderersEnabled()) {
|
||||
var version = String(root.WOWNGEON_RUNTIME.rendererLocalVersion || '');
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version)) return null;
|
||||
var base = '/vendor/three/' + version;
|
||||
return {
|
||||
library: base + '/three.module.min.js',
|
||||
loader: base + '/addons/loaders/GLTFLoader.js',
|
||||
skeleton: base + '/addons/utils/SkeletonUtils.js'
|
||||
};
|
||||
}
|
||||
if (!RK.externalRenderersEnabled()) return null;
|
||||
return {
|
||||
library: 'https://cdn.jsdelivr.net/npm/three@0.160.0/+esm',
|
||||
loader: 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js/+esm',
|
||||
skeleton: 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/utils/SkeletonUtils.js/+esm'
|
||||
};
|
||||
};
|
||||
RK.ensureThree = function (cb) {
|
||||
if (RK.threeReady()) { cb(true); return; }
|
||||
if (!RK.externalRenderersEnabled()) { cb(false); return; }
|
||||
var sources = RK.threeModuleSources();
|
||||
if (!sources) { cb(false); return; }
|
||||
threeCbs.push(cb);
|
||||
if (threeLoading) return;
|
||||
threeLoading = true;
|
||||
Promise.all([
|
||||
import('https://cdn.jsdelivr.net/npm/three@0.160.0/+esm'),
|
||||
import('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js/+esm')
|
||||
import(sources.library),
|
||||
import(sources.loader),
|
||||
import(sources.skeleton)
|
||||
]).then(function (mods) {
|
||||
RK.THREE = { THREE: mods[0], GLTFLoader: mods[1].GLTFLoader };
|
||||
threeLoading = false;
|
||||
RK.THREE = {
|
||||
THREE: mods[0],
|
||||
GLTFLoader: mods[1].GLTFLoader,
|
||||
SkeletonUtils: mods[2]
|
||||
};
|
||||
var cbs = threeCbs; threeCbs = [];
|
||||
cbs.forEach(function (f) { f(true); });
|
||||
}).catch(function (e) {
|
||||
threeLoading = false;
|
||||
if (root.console) console.warn('Three renderer unavailable:', e && e.message);
|
||||
var cbs = threeCbs; threeCbs = [];
|
||||
cbs.forEach(function (f) { f(false); });
|
||||
|
||||
@@ -16,6 +16,19 @@
|
||||
function doc() { return root.document; }
|
||||
function gameDisplay() { return doc() && doc().getElementById('game-display'); }
|
||||
|
||||
// Keyboard movement is intentionally scoped to #game-display so typing in chat and controls
|
||||
// can never move the player. UI controls that affect the live renderer call this immediately
|
||||
// after activation to restore that explicit gameplay focus.
|
||||
SP.focusGameplay = function () {
|
||||
var gd = gameDisplay();
|
||||
if (!gd || !gd.focus) return false;
|
||||
gd.setAttribute('tabindex', '-1');
|
||||
try { gd.focus({ preventScroll: true }); }
|
||||
catch (_) { gd.focus(); }
|
||||
if (root.UI && UI.updateFocusIndicator) UI.updateFocusIndicator();
|
||||
return doc().activeElement === gd;
|
||||
};
|
||||
|
||||
// A dedicated, SELF-SIZING child host so the RK canvas doesn't collide with the ROT canvas and
|
||||
// doesn't depend on the ROT canvas for its dimensions (it's an aspect-ratio viewport the camera
|
||||
// transform is clipped to).
|
||||
@@ -93,7 +106,11 @@
|
||||
b.type = 'button'; b.textContent = label; b.title = title; b.setAttribute('aria-label', title);
|
||||
b.style.cssText = 'min-width:30px;height:28px;padding:0 7px;background:#151b22;color:#e5e7eb;' +
|
||||
'border:1px solid #3b4552;border-radius:4px;cursor:pointer;font:inherit;';
|
||||
b.addEventListener('click', function (e) { e.stopPropagation(); action(); });
|
||||
b.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
action();
|
||||
SP.focusGameplay();
|
||||
});
|
||||
controls.appendChild(b);
|
||||
}
|
||||
addButton('−', 'Zoom out', function () { SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) / 1.18); });
|
||||
@@ -113,9 +130,9 @@
|
||||
var host = rkHost();
|
||||
if (!host || !RK.createRenderer) return null;
|
||||
var mode = SP.mode();
|
||||
// 3D needs THREE, lazy-loaded from a CDN. The sync createRenderer() silently returns a Tiled
|
||||
// renderer when THREE isn't loaded yet ("picked 3D, got original"). Kick off the load and
|
||||
// re-mount as real 3D once it's ready; meanwhile we render Tiled so the screen isn't blank.
|
||||
// 3D needs THREE, lazy-loaded from the pinned same-origin vendor route (or the explicitly
|
||||
// enabled CDN fallback). The sync createRenderer() returns Tiled until it is ready; remount
|
||||
// as real 3D after loading so the screen never goes blank.
|
||||
if (mode === '3d' && RK.ensureThree && RK.threeReady && !RK.threeReady()) {
|
||||
if (!SP._threePending) {
|
||||
SP._threePending = true;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
this.host = host;
|
||||
this.THREE = RK.THREE.THREE;
|
||||
this.GLTFLoader = RK.THREE.GLTFLoader;
|
||||
this.SkeletonUtils = RK.THREE.SkeletonUtils;
|
||||
this.models = {};
|
||||
this.mixers = [];
|
||||
this.entities = {};
|
||||
@@ -26,6 +27,7 @@
|
||||
this.panX = 0;
|
||||
this.panY = 0;
|
||||
this._sizeKey = null;
|
||||
this._destroyed = false;
|
||||
this._init();
|
||||
}
|
||||
|
||||
@@ -66,7 +68,11 @@
|
||||
this.scene.add(this.world);
|
||||
this.clock = new T.Clock();
|
||||
var self = this;
|
||||
this._raf = requestAnimationFrame(function tick() { self._animate(); self._raf = requestAnimationFrame(tick); });
|
||||
this._raf = requestAnimationFrame(function tick() {
|
||||
if (self._destroyed || !self.renderer) { self._raf = null; return; }
|
||||
if (self._animate() === false) { self._raf = null; return; }
|
||||
self._raf = requestAnimationFrame(tick);
|
||||
});
|
||||
};
|
||||
|
||||
// Fill the host and frame a fixed "screenful" around the player. The camera FOLLOWS the player
|
||||
@@ -110,12 +116,31 @@
|
||||
return new T.MeshStandardMaterial({ color: colorNum(hex), roughness: 0.82, metalness: 0.02 });
|
||||
};
|
||||
|
||||
ThreeRenderer.prototype._disposeObject = function (object) {
|
||||
if (!object || !object.traverse) return;
|
||||
object.traverse(function (obj) {
|
||||
if (obj.geometry && obj.geometry.dispose) obj.geometry.dispose();
|
||||
if (!obj.material) return;
|
||||
var materials = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
materials.forEach(function (mat) { if (mat && mat.dispose) mat.dispose(); });
|
||||
});
|
||||
};
|
||||
|
||||
ThreeRenderer.prototype._buildTiles = function (scene) {
|
||||
var T = this.THREE;
|
||||
this.world.clear();
|
||||
// Visibility changes rebuild the floor/walls, but live entities must stay attached to their
|
||||
// stable layer. Clearing the whole world here made every avatar disappear after the first
|
||||
// fog-of-war update because this.entities still pointed at detached objects.
|
||||
if (this.tileLayer) {
|
||||
this.world.remove(this.tileLayer);
|
||||
this._disposeObject(this.tileLayer);
|
||||
}
|
||||
this.tileLayer = new T.Group();
|
||||
this.entityLayer = new T.Group();
|
||||
this.world.add(this.tileLayer, this.entityLayer);
|
||||
this.world.add(this.tileLayer);
|
||||
if (!this.entityLayer) {
|
||||
this.entityLayer = new T.Group();
|
||||
this.world.add(this.entityLayer);
|
||||
}
|
||||
var cx = (scene.cols - 1) / 2, cz = (scene.rows - 1) / 2;
|
||||
for (var y = 0; y < scene.rows; y++) {
|
||||
for (var x = 0; x < scene.cols; x++) {
|
||||
@@ -146,16 +171,16 @@
|
||||
|
||||
ThreeRenderer.prototype._applyModelTint = function (model, visual) {
|
||||
var tint = tintForVisual(visual, null);
|
||||
if (!tint || !model) return;
|
||||
if (!model) return;
|
||||
var T = this.THREE;
|
||||
var color = new T.Color(colorNum(tint));
|
||||
var color = tint ? new T.Color(colorNum(tint)) : null;
|
||||
model.traverse(function (obj) {
|
||||
if (!obj.isMesh || !obj.material) return;
|
||||
var materials = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
var next = materials.map(function (mat) {
|
||||
if (!mat || !mat.color) return mat;
|
||||
if (!mat) return mat;
|
||||
var copy = mat.clone();
|
||||
copy.color.copy(color);
|
||||
if (color && copy.color) copy.color.copy(color);
|
||||
return copy;
|
||||
});
|
||||
obj.material = Array.isArray(obj.material) ? next : next[0];
|
||||
@@ -182,7 +207,7 @@
|
||||
box.getSize(size);
|
||||
box.getCenter(center);
|
||||
var maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
var scale = 5.0 / maxDim;
|
||||
var scale = 1.8 / maxDim;
|
||||
model.position.set(-center.x, -box.min.y, -center.z);
|
||||
wrapper.scale.setScalar(scale);
|
||||
wrapper.add(model);
|
||||
@@ -240,10 +265,17 @@
|
||||
if (visual && visual.allowed !== false && visual.model) {
|
||||
this._loadModel(visual, function (rec) {
|
||||
if (!rec || !rec.gltf || !shell.parent) return;
|
||||
var model = rec.gltf.scene.clone(true);
|
||||
var model = self.SkeletonUtils && self.SkeletonUtils.clone
|
||||
? self.SkeletonUtils.clone(rec.gltf.scene)
|
||||
: rec.gltf.scene.clone(true);
|
||||
self._applyModelTint(model, visual);
|
||||
model.rotation.y = Math.PI;
|
||||
var fitted = self._fitModel(model);
|
||||
if (shell._body) {
|
||||
shell.remove(shell._body);
|
||||
self._disposeObject(shell._body);
|
||||
shell._body = null;
|
||||
}
|
||||
shell.add(fitted);
|
||||
shell._model = fitted;
|
||||
if (rec.gltf.animations && rec.gltf.animations.length) {
|
||||
@@ -275,7 +307,9 @@
|
||||
seen[id] = true;
|
||||
var ent = this.entities[id];
|
||||
if (!ent) {
|
||||
ent = this.entities[id] = { obj: this._makeEntity(e), x: e.x, y: e.y };
|
||||
var obj = this._makeEntity(e);
|
||||
obj.position.set(e.x - cx, 0, e.y - cz);
|
||||
ent = this.entities[id] = { obj: obj, x: e.x, y: e.y };
|
||||
this.entityLayer.add(ent.obj);
|
||||
}
|
||||
ent.e = e;
|
||||
@@ -284,7 +318,14 @@
|
||||
}
|
||||
for (id in this.entities) {
|
||||
if (!seen[id]) {
|
||||
this.entityLayer.remove(this.entities[id].obj);
|
||||
var removed = this.entities[id].obj;
|
||||
this.entityLayer.remove(removed);
|
||||
if (removed._mixer) {
|
||||
removed._mixer.stopAllAction();
|
||||
var mixerIndex = this.mixers.indexOf(removed._mixer);
|
||||
if (mixerIndex >= 0) this.mixers.splice(mixerIndex, 1);
|
||||
}
|
||||
this._disposeObject(removed);
|
||||
delete this.entities[id];
|
||||
}
|
||||
}
|
||||
@@ -348,16 +389,21 @@
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
} catch (err) {
|
||||
if (root.console) console.warn('3D animate error; stopping 3D loop:', err && err.message);
|
||||
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
ThreeRenderer.prototype.destroy = function () {
|
||||
this._destroyed = true;
|
||||
if (this._raf) cancelAnimationFrame(this._raf);
|
||||
this._raf = null;
|
||||
for (var i = 0; i < this.mixers.length; i++) this.mixers[i].stopAllAction();
|
||||
this._disposeObject(this.world);
|
||||
if (this.renderer) {
|
||||
if (this.renderer.domElement && this.renderer.domElement.parentNode) this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
|
||||
this.renderer.dispose();
|
||||
if (this.renderer.forceContextLoss) this.renderer.forceContextLoss();
|
||||
}
|
||||
this.renderer = null;
|
||||
this.entities = {};
|
||||
|
||||
@@ -59,7 +59,10 @@
|
||||
b.setAttribute('aria-disabled', usable ? 'false' : 'true');
|
||||
b.onclick = function () {
|
||||
if (!usable) return;
|
||||
if (RK.SPGame.setMode(m.id)) build();
|
||||
if (RK.SPGame.setMode(m.id)) {
|
||||
build();
|
||||
if (RK.SPGame.focusGameplay) RK.SPGame.focusGameplay();
|
||||
}
|
||||
};
|
||||
modeRow.appendChild(b);
|
||||
});
|
||||
@@ -80,6 +83,7 @@
|
||||
if (RK.setActivePack && RK.setActivePack(p.id)) {
|
||||
if (RK.SPGame.refreshPack) RK.SPGame.refreshPack();
|
||||
build();
|
||||
if (RK.SPGame.focusGameplay) RK.SPGame.focusGameplay();
|
||||
}
|
||||
};
|
||||
packRow.appendChild(b);
|
||||
|
||||
@@ -106,6 +106,9 @@
|
||||
<script src="js/render/isoRenderer.js"></script>
|
||||
<script src="js/render/threeRenderer.js"></script>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script type="importmap">
|
||||
{"imports":{"three":"/vendor/three/0.160.0/three.module.min.js"}}
|
||||
</script>
|
||||
<script src="js/render/renderModes.js"></script>
|
||||
<script src="js/ui/matchUiCopy.js"></script>
|
||||
</head>
|
||||
|
||||
+83
-20
@@ -28,6 +28,9 @@
|
||||
<script src="js/render/threeRenderer.js"></script>
|
||||
<script src="js/render/tavernLife.js"></script>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script type="importmap">
|
||||
{"imports":{"three":"/vendor/three/0.160.0/three.module.min.js"}}
|
||||
</script>
|
||||
<script src="js/render/renderModes.js"></script>
|
||||
<script src="js/ui/matchUiCopy.js"></script>
|
||||
<style>
|
||||
@@ -47,6 +50,14 @@
|
||||
.muted { color:var(--muted);font-size:13px; }
|
||||
.rk-canvas { background:#050609;display:block;max-width:100%;image-rendering:pixelated; }
|
||||
.panel { background:rgba(21,26,32,.97);border:1px solid var(--line);border-radius:9px;padding:14px;margin-bottom:12px;box-shadow:inset 0 1px rgba(255,255,255,.025); }
|
||||
#joinPanel { padding:18px; }
|
||||
.join-copy { display:flex;align-items:baseline;gap:10px;margin-bottom:13px; }
|
||||
.join-copy strong { color:#fff;font-size:18px;letter-spacing:.02em; }
|
||||
.join-fields { display:grid;grid-template-columns:auto minmax(150px,1fr) auto auto;gap:8px;align-items:center; }
|
||||
#joinStatus { display:flex;align-items:center;gap:7px;margin:12px 0 0;min-height:18px;color:var(--muted);font-size:13px; }
|
||||
#joinStatus::before { content:"";width:8px;height:8px;flex:0 0 auto;border-radius:50%;background:var(--gold);box-shadow:0 0 10px rgba(239,182,75,.5); }
|
||||
#joinStatus.connected::before { background:var(--accent);box-shadow:0 0 10px rgba(81,213,105,.55); }
|
||||
#joinStatus.error::before { background:#ff6b6b;box-shadow:0 0 10px rgba(255,107,107,.5); }
|
||||
label { font-size: 13px; margin-right: 6px; }
|
||||
input, select, button { font:inherit;background:#0d1116;color:var(--text);border:1px solid var(--line);border-radius:5px;padding:7px 9px; }
|
||||
input:focus,button:focus-visible { outline:2px solid rgba(98,216,255,.7);outline-offset:2px; }
|
||||
@@ -86,7 +97,7 @@
|
||||
.touch-pad { display:none;grid-template-areas:". up ." "left down right";gap:5px;justify-content:end;margin:8px 0; }
|
||||
.touch-pad button { width:44px;height:40px;padding:0;touch-action:manipulation;background:rgba(25,45,31,.94);color:#93f3a2;border-color:#4f9a5c; }
|
||||
.touch-pad [data-dir="up"]{grid-area:up}.touch-pad [data-dir="left"]{grid-area:left}.touch-pad [data-dir="down"]{grid-area:down}.touch-pad [data-dir="right"]{grid-area:right}
|
||||
@media (max-width:700px),(pointer:coarse) { .touch-pad{display:grid} #stage,.spectator-render{height:min(54vh,500px)} #joinPanel{display:flex;flex-wrap:wrap;gap:7px;align-items:center} #joinPanel label{width:100%} #name{min-width:0;flex:1} }
|
||||
@media (max-width:700px),(pointer:coarse) { .touch-pad{display:grid} #stage,.spectator-render{height:min(54vh,500px)} .join-copy{display:block}.join-copy .muted{display:block;margin-top:5px}.join-fields{grid-template-columns:auto minmax(0,1fr)}.join-fields #customizeBtn,.join-fields #joinBtn{grid-column:auto / span 1}.join-fields label{grid-column:1}.join-fields #name{grid-column:2} }
|
||||
@media (max-width:430px) { .wrap{padding-inline:8px}.tavern-head .muted{display:none}#modebar{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}.modebtn{white-space:nowrap} }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after{animation:none!important;transition:none!important} }
|
||||
</style>
|
||||
@@ -98,13 +109,20 @@
|
||||
<span><a class="backlink" href="/responsible-play">Responsible Play</a> <a class="backlink" href="/">← Lobby</a></span>
|
||||
</header>
|
||||
|
||||
<div id="joinPanel" class="panel">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" maxlength="16" placeholder="anon" autocomplete="off">
|
||||
<button id="customizeBtn" type="button">🎭 Customize</button>
|
||||
<form id="joinPanel" class="panel" autocomplete="off">
|
||||
<div class="join-copy">
|
||||
<strong>Walk in anonymously</strong>
|
||||
<span class="muted">No account required. Pick an optional display name and character.</span>
|
||||
</div>
|
||||
<div class="join-fields">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" maxlength="16" placeholder="anon" autocomplete="nickname">
|
||||
<button id="customizeBtn" type="button">🎭 Customize</button>
|
||||
<button id="joinBtn" type="submit" disabled>Connecting…</button>
|
||||
</div>
|
||||
<span id="appearancePreview" class="muted"></span>
|
||||
<button id="joinBtn">Enter tavern</button>
|
||||
</div>
|
||||
<p id="joinStatus" role="status" aria-live="polite">Connecting to the tavern…</p>
|
||||
</form>
|
||||
|
||||
<div id="modebar" class="hidden"></div>
|
||||
<div id="packbar" class="hidden"></div>
|
||||
@@ -116,7 +134,7 @@
|
||||
<button type="button" data-dir="right" aria-label="Move right">▶</button>
|
||||
</div>
|
||||
<p id="status" class="muted" role="status" aria-live="polite"></p>
|
||||
<p id="movementHint" class="muted">Move with arrow keys or WASD · drag/pinch to explore · ★ may require credits · ⛔ is unavailable on this server.</p>
|
||||
<p id="movementHint" class="muted hidden">Move with arrow keys or WASD · drag/pinch to explore · ★ may require credits · ⛔ is unavailable on this server.</p>
|
||||
|
||||
<div id="chatPanel" class="panel hidden">
|
||||
<div id="chatLog" aria-live="polite"></div>
|
||||
@@ -181,6 +199,7 @@
|
||||
var statusEl = document.getElementById('status');
|
||||
var joinPanel = document.getElementById('joinPanel');
|
||||
var joinBtn = document.getElementById('joinBtn');
|
||||
var joinStatus = document.getElementById('joinStatus');
|
||||
var stageEl = document.getElementById('stage');
|
||||
var modebar = document.getElementById('modebar');
|
||||
var packbar = document.getElementById('packbar');
|
||||
@@ -236,8 +255,28 @@
|
||||
var activeMatchSnapshots = {};
|
||||
var activeMatchTickMs = 250;
|
||||
var currentMatchRuleset = MatchCopy.normalize({ id: 'race' });
|
||||
var joinPending = false;
|
||||
var joinTimer = null;
|
||||
|
||||
function setStatus(msg) { statusEl.textContent = msg; }
|
||||
function setStatus(msg, kind) {
|
||||
statusEl.textContent = msg;
|
||||
joinStatus.textContent = msg;
|
||||
var connectionKind = kind || (socket.connected ? 'connected' : '');
|
||||
joinStatus.classList.toggle('connected', connectionKind === 'connected');
|
||||
joinStatus.classList.toggle('error', connectionKind === 'error');
|
||||
}
|
||||
function updateJoinButton() {
|
||||
joinBtn.disabled = joinPending || !socket.connected;
|
||||
joinBtn.textContent = joinPending
|
||||
? 'Entering…'
|
||||
: (socket.connected ? 'Enter tavern' : 'Connecting…');
|
||||
}
|
||||
function clearJoinAttempt() {
|
||||
if (joinTimer) window.clearTimeout(joinTimer);
|
||||
joinTimer = null;
|
||||
joinPending = false;
|
||||
updateJoinButton();
|
||||
}
|
||||
function applyEntitlements(data) {
|
||||
if (RK.setEntitlementSnapshot) RK.setEntitlementSnapshot(data || {});
|
||||
updateAppearancePreview();
|
||||
@@ -375,16 +414,30 @@
|
||||
}
|
||||
|
||||
// ---- Join / state ------------------------------------------------------------
|
||||
joinBtn.addEventListener('click', function () {
|
||||
joinPanel.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
if (joinPending) return;
|
||||
if (!socket.connected) {
|
||||
setStatus('Not connected yet. Retrying automatically…', 'error');
|
||||
updateJoinButton();
|
||||
return;
|
||||
}
|
||||
var name = document.getElementById('name').value || '';
|
||||
var appearance = RK.normalizeAppearance(selectedAppearance);
|
||||
joinBtn.disabled = true;
|
||||
joinPending = true;
|
||||
updateJoinButton();
|
||||
setStatus('Entering the tavern…', 'connected');
|
||||
socket.emit('tavern_join', { name: name, avatar: appearance.avatar, appearance: appearance });
|
||||
joinTimer = window.setTimeout(function () {
|
||||
if (!joinPending) return;
|
||||
clearJoinAttempt();
|
||||
setStatus('The tavern did not respond. Check the connection and try again.', 'error');
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
socket.on('tavern_error', function (e) {
|
||||
joinBtn.disabled = false;
|
||||
setStatus((e && e.message) || 'Could not enter the tavern.');
|
||||
clearJoinAttempt();
|
||||
setStatus((e && e.message) || 'Could not enter the tavern.', 'error');
|
||||
});
|
||||
socket.on('game_mode_info', function (data) {
|
||||
lastGameModeInfo = data || null;
|
||||
@@ -413,6 +466,7 @@
|
||||
});
|
||||
|
||||
socket.on('tavern_joined', function (data) {
|
||||
clearJoinAttempt();
|
||||
state.you = data.you;
|
||||
applyFull(data.state);
|
||||
joinPanel.classList.add('hidden');
|
||||
@@ -420,6 +474,8 @@
|
||||
stageEl.classList.remove('hidden');
|
||||
chatPanel.classList.remove('hidden');
|
||||
tavernDpad.classList.remove('hidden');
|
||||
movementHint.classList.remove('hidden');
|
||||
stageEl.setAttribute('tabindex', '-1');
|
||||
rebuildScene();
|
||||
buildModebar();
|
||||
setMode(RK.loadMode('tiles'));
|
||||
@@ -437,7 +493,7 @@
|
||||
rebuildScene();
|
||||
draw();
|
||||
});
|
||||
setStatus('You are in the tavern.');
|
||||
setStatus('You are in the tavern.', 'connected');
|
||||
watchGamesBtn.classList.remove('hidden');
|
||||
});
|
||||
|
||||
@@ -550,12 +606,23 @@
|
||||
bindDpad(tavernDpad);
|
||||
bindDpad(spectatorDpad);
|
||||
|
||||
setStatus('Connecting…');
|
||||
setStatus('Connecting to the tavern…');
|
||||
updateJoinButton();
|
||||
socket.on('connect', function () {
|
||||
clearJoinAttempt();
|
||||
socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent });
|
||||
socket.emit('identity:get');
|
||||
socket.emit('match_reconnect', {});
|
||||
setStatus('Connected. Choose a name and enter.');
|
||||
setStatus('Connected. Choose a name and enter.', 'connected');
|
||||
});
|
||||
socket.on('disconnect', function () {
|
||||
clearJoinAttempt();
|
||||
if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear();
|
||||
setStatus('Connection lost. Reconnecting automatically…', 'error');
|
||||
});
|
||||
socket.on('connect_error', function () {
|
||||
clearJoinAttempt();
|
||||
setStatus('Could not connect. Retrying automatically…', 'error');
|
||||
});
|
||||
|
||||
// ---- Shared spectator viewport (solo runs + public PvP races) -----------------
|
||||
@@ -929,10 +996,6 @@
|
||||
|
||||
// ---- End match UI ----------------------------------------------------
|
||||
|
||||
socket.on('disconnect', function () {
|
||||
if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear();
|
||||
setStatus('Disconnected.');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+31
-1
@@ -133,6 +133,12 @@ let latePaymentReconciler = null;
|
||||
let walletHealthInterval = null; // Keeps readiness tied to a live wallet, not startup state
|
||||
let databaseHealthInterval = null; // Detects a database outage after startup
|
||||
const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService);
|
||||
// Three.js is an exact production dependency and is served from this application origin. This
|
||||
// keeps the 3D renderer available without granting a third-party CDN script privileges in the
|
||||
// game/payment UI. The version is part of the public URL so its immutable cache cannot survive an
|
||||
// upgrade to different bytes.
|
||||
const rendererLocalEnabled = true;
|
||||
const rendererLocalVersion = '0.160.0';
|
||||
// Third-party renderer code executes with the same privileges as the game/payment UI. Keep CDN
|
||||
// execution off by default; production operators must make the supply-chain tradeoff explicit.
|
||||
const rendererCdnEnabled = /^true$/i.test(String(process.env.RENDERER_CDN_ENABLED || 'false'));
|
||||
@@ -188,10 +194,32 @@ app.use(['/api/user', '/api/auth', '/api/admin', '/api/payment'], noStore);
|
||||
app.get('/runtime-config.js', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, max-age=0');
|
||||
res.type('application/javascript').send(
|
||||
`window.WOWNGEON_RUNTIME=Object.freeze({rendererCdnEnabled:${rendererCdnEnabled}});`
|
||||
'window.WOWNGEON_RUNTIME=Object.freeze('
|
||||
+ JSON.stringify({ rendererLocalEnabled, rendererLocalVersion, rendererCdnEnabled })
|
||||
+ ');'
|
||||
);
|
||||
});
|
||||
|
||||
// Publish only the browser-facing Three.js build and addons. They remain same-origin under CSP;
|
||||
// package metadata, source maps outside these directories, and the rest of node_modules are not
|
||||
// exposed. Versioned immutable URLs prevent mixed-library deployments during a rolling release.
|
||||
const rendererVendorPath = `/vendor/three/${rendererLocalVersion}`;
|
||||
const threePackagePath = path.join(__dirname, 'node_modules/three');
|
||||
const rendererVendorStaticOptions = {
|
||||
etag: true,
|
||||
immutable: true,
|
||||
lastModified: true,
|
||||
maxAge: '1y'
|
||||
};
|
||||
app.use(
|
||||
`${rendererVendorPath}/addons`,
|
||||
express.static(path.join(threePackagePath, 'examples/jsm'), rendererVendorStaticOptions)
|
||||
);
|
||||
app.use(
|
||||
rendererVendorPath,
|
||||
express.static(path.join(threePackagePath, 'build'), rendererVendorStaticOptions)
|
||||
);
|
||||
|
||||
// Configure static file serving
|
||||
const htmlPath = path.join(__dirname, '../html');
|
||||
app.use(express.static(htmlPath, {
|
||||
@@ -828,6 +856,8 @@ app.get('/api/game-modes', (req, res) => {
|
||||
enabled: publicModeInfo.modes?.match?.enabled === true,
|
||||
economies: publicModeInfo.modes?.match?.economies || {}
|
||||
},
|
||||
rendererLocalEnabled,
|
||||
rendererLocalVersion,
|
||||
rendererCdnEnabled,
|
||||
hostedBy
|
||||
});
|
||||
|
||||
Generated
+7
@@ -18,6 +18,7 @@
|
||||
"resend": "^4.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"three": "0.160.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
@@ -5405,6 +5406,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.160.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
|
||||
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"resend": "^4.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"three": "0.160.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
|
||||
@@ -89,6 +89,16 @@ describe('public multiplayer controls and boards', () => {
|
||||
expect(tavernHtml).toContain("b.addEventListener('click'");
|
||||
});
|
||||
|
||||
test('anonymous Tavern admission has explicit connection, pending, and retry states', () => {
|
||||
expect(tavernHtml).toContain('Walk in anonymously');
|
||||
expect(tavernHtml).toContain('No account required');
|
||||
expect(tavernHtml).toMatch(/id="joinPanel"[^>]*>[\s\S]*id="joinBtn"[^>]*disabled/);
|
||||
expect(tavernHtml).toContain("joinPanel.addEventListener('submit'");
|
||||
expect(tavernHtml).toContain("socket.on('disconnect'");
|
||||
expect(tavernHtml).toContain("socket.on('connect_error'");
|
||||
expect(tavernHtml).toContain('The tavern did not respond');
|
||||
});
|
||||
|
||||
test('Prestige is a distinct public board with an explicit non-mixing disclosure', () => {
|
||||
expect(leaderboard).toContain('data-board="prestige"');
|
||||
expect(leaderboard).toContain('Credit-entry competitive PvP scores only');
|
||||
|
||||
@@ -76,13 +76,18 @@ function loadPickerWithRuntimeDisabled() {
|
||||
context,
|
||||
);
|
||||
window.RK.entitlements.premium = true;
|
||||
window.RK.SPGame = { mode: () => 'tiles', setMode: () => true };
|
||||
const focusGameplay = jest.fn();
|
||||
window.RK.SPGame = {
|
||||
mode: () => 'tiles',
|
||||
setMode: () => true,
|
||||
focusGameplay,
|
||||
};
|
||||
vm.runInNewContext(
|
||||
fs.readFileSync(path.join(__dirname, '../html/js/ui/spRenderPicker.js'), 'utf8'),
|
||||
context,
|
||||
);
|
||||
window.RK.rebuildRenderPicker();
|
||||
return { picker, RK: window.RK };
|
||||
return { picker, RK: window.RK, focusGameplay };
|
||||
}
|
||||
|
||||
describe('optional renderer CDN policy', () => {
|
||||
@@ -118,6 +123,25 @@ describe('optional renderer CDN policy', () => {
|
||||
expect(callback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('the pinned same-origin renderer is available without enabling CDN execution', () => {
|
||||
const RK = loadRenderModes({
|
||||
rendererLocalEnabled: true,
|
||||
rendererLocalVersion: '0.160.0',
|
||||
rendererCdnEnabled: false,
|
||||
});
|
||||
RK.entitlements.premium = true;
|
||||
|
||||
expect(RK.externalRenderersEnabled()).toBe(false);
|
||||
expect(RK.localRenderersEnabled()).toBe(true);
|
||||
expect(RK.isModeRuntimeAvailable('3d')).toBe(true);
|
||||
expect(RK.canUseMode('3d')).toBe(true);
|
||||
expect(RK.threeModuleSources()).toEqual({
|
||||
library: '/vendor/three/0.160.0/three.module.min.js',
|
||||
loader: '/vendor/three/0.160.0/addons/loaders/GLTFLoader.js',
|
||||
skeleton: '/vendor/three/0.160.0/addons/utils/SkeletonUtils.js',
|
||||
});
|
||||
});
|
||||
|
||||
test('homepage picker labels policy-disabled 3D as unavailable, not purchasable', () => {
|
||||
const { picker } = loadPickerWithRuntimeDisabled();
|
||||
const modeButtons = picker.children[0].children;
|
||||
@@ -130,6 +154,15 @@ describe('optional renderer CDN policy', () => {
|
||||
expect(threeButton.attributes['aria-disabled']).toBe('true');
|
||||
});
|
||||
|
||||
test('homepage renderer selection immediately returns keyboard focus to gameplay', () => {
|
||||
const { picker, focusGameplay } = loadPickerWithRuntimeDisabled();
|
||||
const tiledButton = picker.children[0].children.find(button => button.textContent === 'Tiled');
|
||||
|
||||
tiledButton.onclick();
|
||||
|
||||
expect(focusGameplay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('server CSP trusts jsDelivr only when the renderer CDN flag is enabled', () => {
|
||||
const serverSource = fs.readFileSync(
|
||||
path.join(__dirname, '../src/index.js'),
|
||||
@@ -153,4 +186,19 @@ describe('optional renderer CDN policy', () => {
|
||||
expect(debugPage).toContain('js/lib/jquery-3.7.1.min.js');
|
||||
expect(homepage + debugPage).not.toContain('jquery-3.4.1');
|
||||
});
|
||||
|
||||
test('server and renderer pages publish the exact same-origin Three.js build', () => {
|
||||
const packageJson = require('../src/package.json');
|
||||
const serverSource = fs.readFileSync(path.join(__dirname, '../src/index.js'), 'utf8');
|
||||
const pages = ['index.html', 'tavern.html', 'match.html']
|
||||
.map(name => fs.readFileSync(path.join(__dirname, '../html', name), 'utf8'))
|
||||
.join('\n');
|
||||
|
||||
expect(packageJson.dependencies.three).toBe('0.160.0');
|
||||
expect(serverSource).toContain("const rendererLocalVersion = '0.160.0'");
|
||||
expect(serverSource).toContain("'examples/jsm'");
|
||||
expect(serverSource).toContain("'node_modules/three'");
|
||||
expect(pages.split('"three":"/vendor/three/0.160.0/three.module.min.js"'))
|
||||
.toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user