mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-27 10:08:33 +01:00
feat: productionize paid modes and PvP capture
This commit is contained in:
@@ -114,13 +114,17 @@
|
||||
// Camera focus — centre the SP camera on the player cell (same convention as the tiled
|
||||
// renderer). Sticky: only update when the player is present, so switching TO ascii mid-game
|
||||
// doesn't leave focusPoint null (which pinned the whole grid to the corner → black screen).
|
||||
var focusFallback = null;
|
||||
for (var pj = 0; pj < scene.entities.length; pj++) {
|
||||
var pe = scene.entities[pj];
|
||||
if (pe.kind === 'player' || pe.you) {
|
||||
if (pe.kind === 'player' && !focusFallback) focusFallback = pe;
|
||||
if (pe.you || pe.cameraTarget) {
|
||||
this.focusPoint = { x: pe.x * cell + cell / 2, y: pe.y * cell + cell / 2 };
|
||||
focusFallback = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (focusFallback) this.focusPoint = { x: focusFallback.x * cell + cell / 2, y: focusFallback.y * cell + cell / 2 };
|
||||
|
||||
// Keep a light RAF alive so the torch glows flicker between game updates (~22fps).
|
||||
this._hasTorches = hasTorch;
|
||||
|
||||
@@ -624,12 +624,16 @@
|
||||
|
||||
function modeLockedTitle(mode) {
|
||||
var label = mode && mode.label ? mode.label : 'Mode';
|
||||
if (mode && RK.isModeRuntimeAvailable && !RK.isModeRuntimeAvailable(mode.id)) {
|
||||
return label + ' is unavailable on this server because its optional renderer dependency is disabled.';
|
||||
}
|
||||
if (mode && mode.pack) return label + ' is locked. Buy any credits or unlock ' + packLabel(mode.pack) + '.';
|
||||
return label + ' is locked. Buy any credits to unlock premium modes.';
|
||||
}
|
||||
|
||||
function modeStatus(mode, unlocked) {
|
||||
if (!mode || !mode.premium) return 'Included';
|
||||
if (RK.isModeRuntimeAvailable && !RK.isModeRuntimeAvailable(mode.id)) return 'Unavailable';
|
||||
if (unlocked && RK.renderModeTestUnlocks && RK.renderModeTestUnlocks()) return 'Test';
|
||||
return unlocked ? 'Unlocked' : 'Locked';
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
this.cell = opts.cell || 26;
|
||||
this.app = new PIXI.Application({ backgroundColor: 0x0a0c0f, antialias: true });
|
||||
this.app.view.className = 'rk-canvas';
|
||||
this.canvas = this.app.view;
|
||||
host.appendChild(this.app.view);
|
||||
|
||||
this.tileLayer = new PIXI.Container();
|
||||
@@ -288,7 +289,7 @@
|
||||
};
|
||||
|
||||
FancyRenderer.prototype._syncEntities = function (scene) {
|
||||
var cell = this.cell, seen = {}, RKr = window.RK;
|
||||
var cell = this.cell, seen = {}, RKr = window.RK, focusFallback = null;
|
||||
for (var i = 0; i < scene.entities.length; i++) {
|
||||
var e = scene.entities[i];
|
||||
seen[e.id] = true;
|
||||
@@ -315,6 +316,14 @@
|
||||
s = this.sprites[e.id] = { c: c, kind: kind, renderKey: renderKey };
|
||||
}
|
||||
s.tx = e.x; s.ty = e.y; s.e = e;
|
||||
if (e.kind === 'player' && !focusFallback) focusFallback = e;
|
||||
if (e.you || e.cameraTarget) {
|
||||
this.focusPoint = { x: e.x * cell + cell / 2, y: e.y * cell + cell / 2 };
|
||||
focusFallback = null;
|
||||
}
|
||||
}
|
||||
if (!this.focusPoint && focusFallback) {
|
||||
this.focusPoint = { x: focusFallback.x * cell + cell / 2, y: focusFallback.y * cell + cell / 2 };
|
||||
}
|
||||
for (var id in this.sprites) {
|
||||
if (!seen[id]) {
|
||||
|
||||
@@ -326,8 +326,9 @@
|
||||
var e = scene.entities[i];
|
||||
p = this._project(e.x, e.y, originX, originY);
|
||||
items.push({ type: 'entity', e: e, sx: p.x, sy: p.y, depth: e.x + e.y + 0.55 });
|
||||
// Sticky camera target (keep last if the player is momentarily absent).
|
||||
if (e.you || e.kind === 'player') { this.focusPoint = { x: p.x, y: p.y + this.tileH }; this._plx = e.x; this._ply = e.y; }
|
||||
// Sticky camera target (keep last if the player is momentarily absent). Multiplayer
|
||||
// scenes explicitly mark one racer as cameraTarget; never let a later rival steal it.
|
||||
if (e.you || e.cameraTarget) { this.focusPoint = { x: p.x, y: p.y + this.tileH }; this._plx = e.x; this._ply = e.y; }
|
||||
}
|
||||
items.sort(function (a, b) { return a.depth === b.depth ? (a.y || 0) - (b.y || 0) : a.depth - b.depth; });
|
||||
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var RK = root.RK = root.RK || {};
|
||||
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 true;
|
||||
};
|
||||
|
||||
// Render tiers. `premium` marks modes intended to be unlocked with credits — a cosmetic
|
||||
// entitlement gated by Operator Policy, the same pattern as avatar unlocks. NOTE: rendering
|
||||
@@ -39,6 +49,7 @@
|
||||
RK.canUseMode = function (id) {
|
||||
var m = RK.modeMeta(id);
|
||||
if (!m) return false;
|
||||
if (!RK.isModeRuntimeAvailable(id)) return false;
|
||||
if (RK.renderModeTestUnlocks && RK.renderModeTestUnlocks()) return true;
|
||||
if (!m.premium) return true;
|
||||
// A premium mode is available once the user has ANY unlocked pack for its projection.
|
||||
@@ -47,6 +58,16 @@
|
||||
return !!RK.entitlements.premium;
|
||||
};
|
||||
|
||||
RK.modeAvailability = function (id) {
|
||||
var runtimeAvailable = RK.isModeRuntimeAvailable(id);
|
||||
var usable = runtimeAvailable && RK.canUseMode(id);
|
||||
return {
|
||||
usable: usable,
|
||||
runtimeAvailable: runtimeAvailable,
|
||||
reason: !runtimeAvailable ? 'runtime_unavailable' : (usable ? null : 'entitlement_locked')
|
||||
};
|
||||
};
|
||||
|
||||
RK.createRenderer = function (mode, host, opts) {
|
||||
if (!RK.canUseMode(mode)) return new RK.TileRenderer(host, opts);
|
||||
if (mode === 'ascii' && RK.AsciiRenderer) return new RK.AsciiRenderer(host, opts);
|
||||
@@ -76,6 +97,7 @@
|
||||
RK.pixiReady = function () { return typeof PIXI !== 'undefined'; };
|
||||
RK.ensurePixi = function (cb) {
|
||||
if (RK.pixiReady()) { cb(true); return; }
|
||||
if (!RK.externalRenderersEnabled()) { cb(false); return; }
|
||||
pixiCbs.push(cb);
|
||||
if (pixiLoading) return;
|
||||
pixiLoading = true;
|
||||
@@ -96,6 +118,7 @@
|
||||
RK.threeReady = function () { return !!(RK.THREE && RK.THREE.THREE); };
|
||||
RK.ensureThree = function (cb) {
|
||||
if (RK.threeReady()) { cb(true); return; }
|
||||
if (!RK.externalRenderersEnabled()) { cb(false); return; }
|
||||
threeCbs.push(cb);
|
||||
if (threeLoading) return;
|
||||
threeLoading = true;
|
||||
|
||||
@@ -155,9 +155,67 @@ function maxDims(maps) {
|
||||
return { rows: rows, cols: cols };
|
||||
}
|
||||
|
||||
function normalizeGameStateOpts(opts) {
|
||||
// Older multiplayer callers passed a socket id directly. Keep that path working while the
|
||||
// public API moves to an options object (`{ viewerId, playerAppearance, cryptoType }`).
|
||||
if (typeof opts === 'string') return { viewerId: opts };
|
||||
return (opts && typeof opts === 'object') ? opts : {};
|
||||
}
|
||||
|
||||
function pickCameraPlayer(players, state, opts) {
|
||||
if (!players || !players.length) return null;
|
||||
var viewerId = opts.viewerId || opts.socketId || null;
|
||||
var focusId = opts.focusPlayerId || null;
|
||||
var i, p;
|
||||
|
||||
// Match ticks are broadcast once, so the client socket id is the authoritative local marker
|
||||
// when the server cannot include a recipient-specific `you` flag.
|
||||
if (viewerId) {
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && p.id === viewerId) return p;
|
||||
}
|
||||
}
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && p.you === true) return p;
|
||||
}
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && focusId && p.id === focusId) return p;
|
||||
}
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && state.winnerId && p.id === state.winnerId) return p;
|
||||
}
|
||||
// Spectators follow a live contender by default, then gracefully fall back to the first racer.
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && p.alive !== false && !p.finished) return p;
|
||||
}
|
||||
return players[0] || null;
|
||||
}
|
||||
|
||||
function pointOf(value) {
|
||||
if (!value) return null;
|
||||
if (Array.isArray(value)) return { x: value[0], y: value[1] };
|
||||
if (typeof value.x === 'number' && typeof value.y === 'number') return { x: value.x, y: value.y };
|
||||
return null;
|
||||
}
|
||||
|
||||
function racerColor(player, index, isYou) {
|
||||
if (isYou) return '#67e8f9';
|
||||
if (player && player.alive === false) return '#6b7280';
|
||||
if (player && (player.finished || player.escaped)) return '#fbbf24';
|
||||
var palette = ['#9aa4b2', '#a78bfa', '#4ade80', '#fb7185', '#60a5fa', '#f97316'];
|
||||
var raw = String((player && player.id) || index), hash = 0;
|
||||
for (var i = 0; i < raw.length; i++) hash = ((hash * 31) + raw.charCodeAt(i)) >>> 0;
|
||||
return palette[hash % palette.length];
|
||||
}
|
||||
|
||||
function sceneFromGameState(state, opts) {
|
||||
state = state || {};
|
||||
opts = opts || {};
|
||||
opts = normalizeGameStateOpts(opts);
|
||||
var visible = state.visibleTiles || {};
|
||||
var explored = state.exploredTiles || {};
|
||||
var lighting = state.lighting || {};
|
||||
@@ -167,8 +225,12 @@ function sceneFromGameState(state, opts) {
|
||||
var rows = state.dungeonRows || visible.length || dims.rows;
|
||||
var cols = state.dungeonCols || dims.cols;
|
||||
|
||||
// Player cell — explored MEMORY fades to black with distance from here (see below).
|
||||
var _pl = state.player || {};
|
||||
var matchPlayers = Array.isArray(state.players) ? state.players : [];
|
||||
var cameraPlayer = pickCameraPlayer(matchPlayers, state, opts);
|
||||
|
||||
// Player cell — explored MEMORY fades to black with distance from here (see below). Multiplayer
|
||||
// states use `players[]`, while the original single-player protocol uses singular `player`.
|
||||
var _pl = state.player || cameraPlayer || {};
|
||||
var _plx = typeof _pl.x === 'number' ? _pl.x : (cols / 2);
|
||||
var _ply = typeof _pl.y === 'number' ? _pl.y : (rows / 2);
|
||||
|
||||
@@ -215,15 +277,19 @@ function sceneFromGameState(state, opts) {
|
||||
function inView(x, y) { return !!(visible[y] && visible[y][x] !== undefined); }
|
||||
|
||||
// Entrance / exit / treasure as entities (so they layer above tiles) — only once explored.
|
||||
if (state.entrance && seen(state.entrance[0], state.entrance[1])) {
|
||||
entities.push({ id: 'entrance', x: state.entrance[0], y: state.entrance[1], kind: 'feature', char: '<', color: '#3fb950', label: null });
|
||||
// Solo uses `[x,y]`; match state uses `{x,y}` for treasure, so accept both point shapes.
|
||||
var entrance = pointOf(state.entrance);
|
||||
var exit = pointOf(state.exit);
|
||||
var treasure = pointOf(state.treasure);
|
||||
if (entrance && seen(entrance.x, entrance.y)) {
|
||||
entities.push({ id: 'entrance', x: entrance.x, y: entrance.y, kind: 'feature', char: '<', color: '#3fb950', label: null });
|
||||
}
|
||||
if (state.exit && seen(state.exit[0], state.exit[1])) {
|
||||
entities.push({ id: 'exit', x: state.exit[0], y: state.exit[1], kind: 'feature', char: '>', color: '#d29922', label: null });
|
||||
if (exit && seen(exit.x, exit.y)) {
|
||||
entities.push({ id: 'exit', x: exit.x, y: exit.y, kind: 'feature', char: '>', color: '#d29922', label: null });
|
||||
}
|
||||
if (state.treasure && seen(state.treasure[0], state.treasure[1])) {
|
||||
if (treasure && seen(treasure.x, treasure.y) && !(state.treasure && state.treasure.carrierId)) {
|
||||
var tChar = opts.cryptoType === 'XMR' ? '$M' : '$W';
|
||||
entities.push({ id: 'treasure', x: state.treasure[0], y: state.treasure[1], kind: 'feature', char: tChar, color: '#fbbf24', label: null });
|
||||
entities.push({ id: 'treasure', x: treasure.x, y: treasure.y, kind: 'feature', char: tChar, color: '#fbbf24', label: null });
|
||||
}
|
||||
|
||||
// Items — only once explored.
|
||||
@@ -246,15 +312,34 @@ function sceneFromGameState(state, opts) {
|
||||
});
|
||||
}
|
||||
|
||||
// Player.
|
||||
if (state.player) {
|
||||
// Multiplayer racers. Preserve public status fields useful to HUD/render extensions while
|
||||
// keeping the shared entity contract identical to solo (`kind: player`, appearance, facing).
|
||||
if (matchPlayers.length) {
|
||||
var viewerId = opts.viewerId || opts.socketId || null;
|
||||
for (var pi = 0; pi < matchPlayers.length; pi++) {
|
||||
var rp = matchPlayers[pi];
|
||||
if (!rp || typeof rp.x !== 'number' || typeof rp.y !== 'number') continue;
|
||||
var isYou = viewerId ? rp.id === viewerId : rp.you === true;
|
||||
var appearance = rp.appearance || (rp.avatar ? { avatar: rp.avatar } : null) || { avatar: 'default' };
|
||||
entities.push({
|
||||
id: rp.id || ('player:' + pi), x: rp.x, y: rp.y,
|
||||
kind: 'player', char: '@', color: racerColor(rp, pi, isYou),
|
||||
facing: rp.facing || null, label: rp.name || null,
|
||||
avatar: appearance.avatar || rp.avatar || 'default', appearance: appearance,
|
||||
you: isYou, cameraTarget: rp === cameraPlayer,
|
||||
alive: rp.alive !== false, finished: !!rp.finished, escaped: !!rp.escaped,
|
||||
hasTreasure: !!rp.hasTreasure, placement: rp.placement == null ? null : rp.placement
|
||||
});
|
||||
}
|
||||
// Original single-player protocol.
|
||||
} else if (state.player) {
|
||||
var playerEntity = {
|
||||
id: 'player', x: state.player.x, y: state.player.y,
|
||||
kind: 'player', char: '@', color: '#9aa4b2',
|
||||
// No bogus default: the SP server doesn't send facing, so leave it null and let the iso/3D
|
||||
// renderers INFER facing from movement. A hardcoded 'down' made them always face down/SW.
|
||||
facing: state.player.facing || null, label: null,
|
||||
you: !state.isSpectating
|
||||
you: !state.isSpectating, cameraTarget: true
|
||||
};
|
||||
// Attach the player's appearance if available (for render-kit character rendering).
|
||||
if (opts.playerAppearance) {
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
|
||||
SP.setMode = function (mode) {
|
||||
if (RK.canUseMode && !RK.canUseMode(mode)) return false;
|
||||
if (SP._mode !== mode) SP._zoom = null; // let the new projection use its legible default
|
||||
SP._mode = mode;
|
||||
if (RK.saveMode) RK.saveMode(mode);
|
||||
SP._destroyRenderer();
|
||||
@@ -71,6 +72,42 @@
|
||||
SP._renderer = null;
|
||||
};
|
||||
|
||||
SP._setZoom = function (next) {
|
||||
SP._zoom = Math.max(0.4, Math.min(4, Number(next) || SP._defaultZoom()));
|
||||
var readout = doc() && doc().getElementById('rk-sp-camera-zoom');
|
||||
if (readout) readout.textContent = Math.round(SP._zoom * 100) + '%';
|
||||
SP._applyCamera();
|
||||
};
|
||||
|
||||
SP._mountCameraControls = function (host) {
|
||||
if (!host || host._rkCameraControls || !doc()) return;
|
||||
var controls = doc().createElement('div');
|
||||
controls.id = 'rk-sp-camera-controls';
|
||||
controls.className = 'rk-camera-controls';
|
||||
controls.setAttribute('aria-label', 'Dungeon camera controls');
|
||||
controls.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:20;display:flex;' +
|
||||
'align-items:center;gap:4px;padding:4px;background:rgba(4,7,10,.82);border:1px solid rgba(255,255,255,.2);' +
|
||||
'border-radius:7px;box-shadow:0 4px 18px rgba(0,0,0,.45);font:12px monospace;';
|
||||
function addButton(label, title, action) {
|
||||
var b = doc().createElement('button');
|
||||
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(); });
|
||||
controls.appendChild(b);
|
||||
}
|
||||
addButton('−', 'Zoom out', function () { SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) / 1.18); });
|
||||
var readout = doc().createElement('span');
|
||||
readout.id = 'rk-sp-camera-zoom';
|
||||
readout.style.cssText = 'min-width:42px;text-align:center;color:#a7f3d0;';
|
||||
controls.appendChild(readout);
|
||||
addButton('+', 'Zoom in', function () { SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) * 1.18); });
|
||||
addButton('⌂', 'Reset camera', function () { SP._zoom = null; SP._camX = null; SP._setZoom(SP._defaultZoom()); });
|
||||
host.appendChild(controls);
|
||||
host._rkCameraControls = controls;
|
||||
readout.textContent = Math.round(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) * 100) + '%';
|
||||
};
|
||||
|
||||
SP._ensureRenderer = function () {
|
||||
if (SP._renderer) return SP._renderer;
|
||||
var host = rkHost();
|
||||
@@ -116,17 +153,42 @@
|
||||
// NOT a hardcoded 1.7 — otherwise the first scroll on iso (default 1.0) jumped up to
|
||||
// 1.7 and BOTH directions read as "zoom in".
|
||||
var base = (SP._zoom != null) ? SP._zoom : SP._defaultZoom();
|
||||
SP._zoom = Math.max(0.4, Math.min(4, base * Math.exp(-ev.deltaY * 0.0015)));
|
||||
SP._applyCamera();
|
||||
SP._setZoom(base * Math.exp(-ev.deltaY * 0.0015));
|
||||
}, { passive: false });
|
||||
host.addEventListener('dblclick', function (ev) {
|
||||
if (ev.target && ev.target.closest && ev.target.closest('.rk-camera-controls')) return;
|
||||
SP._zoom = null; SP._camX = null; SP._setZoom(SP._defaultZoom());
|
||||
});
|
||||
var points = {}, pinchDistance = null;
|
||||
host.addEventListener('pointerdown', function (ev) {
|
||||
if (ev.target && ev.target.closest && ev.target.closest('.rk-camera-controls')) return;
|
||||
points[ev.pointerId] = { x: ev.clientX, y: ev.clientY };
|
||||
try { host.setPointerCapture(ev.pointerId); } catch (_) {}
|
||||
});
|
||||
host.addEventListener('pointermove', function (ev) {
|
||||
if (!points[ev.pointerId]) return;
|
||||
points[ev.pointerId] = { x: ev.clientX, y: ev.clientY };
|
||||
var ids = Object.keys(points);
|
||||
if (ids.length < 2) return;
|
||||
var a = points[ids[0]], b = points[ids[1]];
|
||||
var distance = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
if (pinchDistance) SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) * (distance / pinchDistance));
|
||||
pinchDistance = distance;
|
||||
});
|
||||
function release(ev) { delete points[ev.pointerId]; pinchDistance = null; }
|
||||
host.addEventListener('pointerup', release);
|
||||
host.addEventListener('pointercancel', release);
|
||||
}
|
||||
SP._mountCameraControls(host);
|
||||
return SP._renderer;
|
||||
};
|
||||
|
||||
// Mode-aware default zoom: iso tiles are large (84px) so they want less magnification than the
|
||||
// small tiled/ascii cells (~24px).
|
||||
SP._defaultZoom = function () {
|
||||
return (SP._renderer && SP._renderer.name === 'iso') ? 1.0 : 1.7;
|
||||
if (SP._renderer && SP._renderer.name === 'iso') return 1.0;
|
||||
if (SP._renderer && SP._renderer.name === '3d') return 1.05;
|
||||
return 1.7;
|
||||
};
|
||||
|
||||
// Centre the (whole-scene) canvas on the player via a CSS transform, clipped to the host —
|
||||
@@ -138,6 +200,7 @@
|
||||
// The 3D renderer fills the host itself (setSize) and its OWN THREE camera follows the
|
||||
// player — so DON'T apply a CSS transform (it would double-transform the WebGL view).
|
||||
if (r.name === '3d') {
|
||||
if (r.setZoom) r.setZoom((SP._zoom != null) ? SP._zoom : SP._defaultZoom());
|
||||
r.canvas.style.transform = 'none';
|
||||
r.canvas.style.left = '0';
|
||||
r.canvas.style.top = '0';
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
targetX: x, targetY: y,
|
||||
moveCooldown: 0,
|
||||
sitTimer: 0,
|
||||
speechTimer: 0,
|
||||
// Stagger the first lines so entering the room feels conversational instead of every
|
||||
// patron opening a bubble on the same frame.
|
||||
speechTimer: opts.speechTimer == null ? 1800 + Math.random() * 7000 : opts.speechTimer,
|
||||
speech: null, speechUntil: 0,
|
||||
color: opts.color || '#d7dbe0',
|
||||
label: opts.label || null,
|
||||
@@ -334,6 +336,7 @@
|
||||
// Get the screen position of an entity for a given renderer/cell size.
|
||||
// This is renderer-agnostic: top-down uses grid coords * cell; iso uses projection.
|
||||
TavernLife.prototype._entityScreenPos = function (e, scene, cell, mode) {
|
||||
var pos;
|
||||
if (mode === 'iso') {
|
||||
// Match IsoRenderer._project: originX = margin + rows * tileW/2 + tileW; originY = margin + imageH
|
||||
var tileW = (RK.isoAssets && RK.isoAssets.tile && RK.isoAssets.tile.w) || 84;
|
||||
@@ -342,18 +345,21 @@
|
||||
var margin = 28;
|
||||
var originX = margin + scene.rows * tileW / 2 + tileW;
|
||||
var originY = margin + imageH;
|
||||
return {
|
||||
pos = {
|
||||
x: originX + (e.x - e.y) * tileW / 2,
|
||||
y: originY + (e.x + e.y) * tileH / 2
|
||||
};
|
||||
}
|
||||
if (mode === '3d') {
|
||||
} else if (mode === '3d') {
|
||||
// 3D renderer uses Three.js — we can't easily get screen coords.
|
||||
// Skip speech bubbles for 3D mode.
|
||||
return null;
|
||||
} else {
|
||||
// top-down / ascii / fancy: grid coords * cell
|
||||
pos = { x: e.x * cell + cell / 2, y: e.y * cell + cell / 2 };
|
||||
}
|
||||
// top-down / ascii / fancy: grid coords * cell
|
||||
return { x: e.x * cell + cell / 2, y: e.y * cell + cell / 2 };
|
||||
var view = this._viewTransform;
|
||||
if (view) return { x: view.left + pos.x * view.scaleX, y: view.top + pos.y * view.scaleY };
|
||||
return pos;
|
||||
};
|
||||
|
||||
TavernLife.prototype._drawBubbles = function (scene, cell, mode, now) {
|
||||
@@ -369,7 +375,7 @@
|
||||
var remaining = npc.speechUntil - now;
|
||||
if (remaining < BUBBLE_FADE) alpha = remaining / BUBBLE_FADE;
|
||||
var pos = this._entityScreenPos(npc, scene, cell, mode);
|
||||
if (pos) drawBubble(ctx, pos.x, pos.y - cell * 0.3, npc.speech, alpha);
|
||||
if (pos) drawBubble(ctx, pos.x, pos.y - cell * 0.3 * ((this._viewTransform && this._viewTransform.scaleY) || 1), npc.speech, alpha);
|
||||
}
|
||||
|
||||
// Real player bubbles (from chat).
|
||||
@@ -386,7 +392,7 @@
|
||||
var rRemaining = bubble.until - now;
|
||||
if (rRemaining < BUBBLE_FADE) rAlpha = rRemaining / BUBBLE_FADE;
|
||||
var rPos = this._entityScreenPos(e, scene, cell, mode);
|
||||
if (rPos) drawBubble(ctx, rPos.x, rPos.y - cell * 0.3, bubble.text, rAlpha);
|
||||
if (rPos) drawBubble(ctx, rPos.x, rPos.y - cell * 0.3 * ((this._viewTransform && this._viewTransform.scaleY) || 1), bubble.text, rAlpha);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -419,7 +425,26 @@
|
||||
// Draw speech bubbles on the overlay.
|
||||
var rendererCanvas = this.host.querySelector('.rk-canvas');
|
||||
if (rendererCanvas) {
|
||||
this._syncOverlaySize(rendererCanvas.width, rendererCanvas.height);
|
||||
this._syncOverlaySize(this.host.clientWidth || rendererCanvas.width, this.host.clientHeight || rendererCanvas.height);
|
||||
// Keep the overlay at viewport scale (so chat remains readable), but project each
|
||||
// world-space head position through the camera-transformed canvas rectangle.
|
||||
var hostRect = this.host.getBoundingClientRect ? this.host.getBoundingClientRect() : { left: 0, top: 0 };
|
||||
var canvasRect = rendererCanvas.getBoundingClientRect ? rendererCanvas.getBoundingClientRect() : {
|
||||
left: 0, top: 0, width: rendererCanvas.width, height: rendererCanvas.height
|
||||
};
|
||||
this._viewTransform = {
|
||||
left: canvasRect.left - hostRect.left,
|
||||
top: canvasRect.top - hostRect.top,
|
||||
scaleX: canvasRect.width / (rendererCanvas.width || 1),
|
||||
scaleY: canvasRect.height / (rendererCanvas.height || 1)
|
||||
};
|
||||
this._overlay.style.inset = '0';
|
||||
this._overlay.style.left = '0';
|
||||
this._overlay.style.top = '0';
|
||||
this._overlay.style.maxWidth = 'none';
|
||||
this._overlay.style.maxHeight = 'none';
|
||||
this._overlay.style.transform = '';
|
||||
this._overlay.style.transition = '';
|
||||
}
|
||||
this._drawBubbles(scene, cell, mode, now);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
this.mixers = [];
|
||||
this.entities = {};
|
||||
this.last = {};
|
||||
this.zoom = 1;
|
||||
this.panX = 0;
|
||||
this.panY = 0;
|
||||
this._sizeKey = null;
|
||||
this._init();
|
||||
}
|
||||
@@ -40,7 +43,13 @@
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
this.renderer = new T.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||
this.renderer.domElement.className = 'rk-canvas';
|
||||
this.canvas = this.renderer.domElement;
|
||||
this.renderer.setPixelRatio(Math.min(2, root.devicePixelRatio || 1));
|
||||
if (T.SRGBColorSpace) this.renderer.outputColorSpace = T.SRGBColorSpace;
|
||||
if (T.ACESFilmicToneMapping != null) {
|
||||
this.renderer.toneMapping = T.ACESFilmicToneMapping;
|
||||
this.renderer.toneMappingExposure = 1.08;
|
||||
}
|
||||
this.host.appendChild(this.renderer.domElement);
|
||||
|
||||
var hemi = new T.HemisphereLight(0xfff3d2, 0x283044, 2.1);
|
||||
@@ -48,6 +57,11 @@
|
||||
var key = new T.DirectionalLight(0xffffff, 1.6);
|
||||
key.position.set(6, 10, 5);
|
||||
this.scene.add(key);
|
||||
// A subtle player-carried torch adds depth and keeps the followed avatar readable without
|
||||
// washing out the dungeon's black fog-of-war background.
|
||||
this.playerLight = new T.PointLight(0xffb35c, 2.2, 8, 2);
|
||||
this.playerLight.position.set(0, 3.2, 0);
|
||||
this.scene.add(this.playerLight);
|
||||
this.world = new T.Group();
|
||||
this.scene.add(this.world);
|
||||
this.clock = new T.Clock();
|
||||
@@ -65,7 +79,7 @@
|
||||
if (w === this._cw && h === this._ch) return;
|
||||
this._cw = w; this._ch = h;
|
||||
this.renderer.setSize(w, h, true); // updateStyle:true → the canvas CSS-fills the host
|
||||
var aspect = w / h, span = 8; // fixed zoom = a screenful around the player
|
||||
var aspect = w / h, span = 8 / (this.zoom || 1); // fixed screenful, user-adjustable zoom
|
||||
this.camera.left = -span * aspect;
|
||||
this.camera.right = span * aspect;
|
||||
this.camera.top = span;
|
||||
@@ -73,6 +87,24 @@
|
||||
this.camera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
ThreeRenderer.prototype.setZoom = function (zoom) {
|
||||
var next = Math.max(0.55, Math.min(3.2, Number(zoom) || 1));
|
||||
if (Math.abs(next - this.zoom) < 0.001) return;
|
||||
this.zoom = next;
|
||||
this._cw = null; // force projection update even when the host dimensions did not change
|
||||
this._fitToHost();
|
||||
};
|
||||
|
||||
// `attachCamera` reports drag pan in viewport pixels. Keep that contract for 3D and convert the
|
||||
// offset to ground-plane world units in _animate(), where the followed player's current smoothed
|
||||
// position is available. Bound it to one viewport so a stray gesture cannot lose the dungeon.
|
||||
ThreeRenderer.prototype.setPan = function (x, y) {
|
||||
var w = (this.host && (this.host.clientWidth || this.host.offsetWidth)) || 640;
|
||||
var h = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
|
||||
this.panX = Math.max(-w, Math.min(w, Number(x) || 0));
|
||||
this.panY = Math.max(-h, Math.min(h, Number(y) || 0));
|
||||
};
|
||||
|
||||
ThreeRenderer.prototype._mat = function (hex) {
|
||||
var T = this.THREE;
|
||||
return new T.MeshStandardMaterial({ color: colorNum(hex), roughness: 0.82, metalness: 0.02 });
|
||||
@@ -297,11 +329,21 @@
|
||||
var pcam = null;
|
||||
for (var pid in this.entities) {
|
||||
var pen = this.entities[pid];
|
||||
if (pen.e && (pen.e.you || pen.e.kind === 'player')) { pcam = pen.obj; break; }
|
||||
if (pen.e && (pen.e.you || pen.e.cameraTarget)) { pcam = pen.obj; break; }
|
||||
if (!pcam && pen.e && pen.e.kind === 'player') pcam = pen.obj;
|
||||
}
|
||||
if (pcam) {
|
||||
this.camera.position.set(pcam.position.x + 8, 9, pcam.position.z + 8);
|
||||
this.camera.lookAt(pcam.position.x, 0, pcam.position.z);
|
||||
// Dragging the viewport should move the rendered dungeon with the pointer. Translate the
|
||||
// camera target opposite the corresponding screen-space axes while retaining the fixed
|
||||
// isometric camera offset and player-follow smoothing.
|
||||
var viewportHeight = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
|
||||
var unitsPerPixel = (16 / (this.zoom || 1)) / Math.max(1, viewportHeight);
|
||||
var diagonal = unitsPerPixel / Math.sqrt(2);
|
||||
var targetX = pcam.position.x + (-this.panX - this.panY) * diagonal;
|
||||
var targetZ = pcam.position.z + (this.panX - this.panY) * diagonal;
|
||||
this.camera.position.set(targetX + 8, 9, targetZ + 8);
|
||||
this.camera.lookAt(targetX, 0, targetZ);
|
||||
if (this.playerLight) this.playerLight.position.set(pcam.position.x, 3.2, pcam.position.z);
|
||||
}
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
} catch (err) {
|
||||
|
||||
@@ -41,11 +41,15 @@
|
||||
// The player/you entity that carries the torch (for the light composite + embers).
|
||||
function findPlayer(scene) {
|
||||
if (!scene || !scene.entities) return null;
|
||||
var live = null, fallback = null;
|
||||
for (var i = 0; i < scene.entities.length; i++) {
|
||||
var e = scene.entities[i];
|
||||
if (e.kind === 'player' || e.you) return e;
|
||||
if (!(e.kind === 'player' || e.you)) continue;
|
||||
if (e.you || e.cameraTarget) return e;
|
||||
if (!live && e.alive !== false && !e.finished) live = e;
|
||||
if (!fallback) fallback = e;
|
||||
}
|
||||
return null;
|
||||
return live || fallback;
|
||||
}
|
||||
|
||||
// Real-tile coord for a dungeon feature entity, keyed off its glyph.
|
||||
|
||||
@@ -37,4 +37,239 @@
|
||||
RK.applyZoom(host);
|
||||
});
|
||||
};
|
||||
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function rendererCanvas(renderer) {
|
||||
return renderer && (renderer.canvas || (renderer.app && renderer.app.view) ||
|
||||
(renderer.renderer && renderer.renderer.domElement));
|
||||
}
|
||||
|
||||
// Player-follow camera for fixed viewports (single/multiplayer races and spectator views).
|
||||
// Renderers only need to expose `canvas` + `focusPoint`; ThreeRenderer instead handles zoom and
|
||||
// following internally through setZoom(). The older attachZoom API above remains unchanged for
|
||||
// whole-room previews that intentionally do not follow an avatar.
|
||||
RK.attachCamera = function (host, opts) {
|
||||
if (!host) return null;
|
||||
if (host._rkCamera) return host._rkCamera.api;
|
||||
opts = opts || {};
|
||||
var state = host._rkCamera = {
|
||||
zoom: clamp(Number(opts.zoom) || 1.35, Number(opts.min) || 0.55, Number(opts.max) || 3.2),
|
||||
defaultZoom: Number(opts.zoom) || 1.35,
|
||||
fitRatio: Number(opts.fitRatio) || 0,
|
||||
autoFit: false,
|
||||
min: Number(opts.min) || 0.55,
|
||||
max: Number(opts.max) || 3.2,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
renderer: null,
|
||||
pointers: {},
|
||||
pinchDistance: null,
|
||||
listeners: [],
|
||||
destroyed: false
|
||||
};
|
||||
host.style.position = host.style.position || 'relative';
|
||||
host.style.overflow = 'hidden';
|
||||
host.style.touchAction = 'none';
|
||||
host.style.cursor = 'grab';
|
||||
|
||||
function updateReadout() {
|
||||
if (state.readout) state.readout.textContent = Math.round(state.zoom * 100) + '%';
|
||||
}
|
||||
function listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
state.listeners.push({ target: target, type: type, handler: handler, options: options });
|
||||
}
|
||||
function fitZoom(renderer, ratio) {
|
||||
if (!renderer || renderer.name === '3d') return clamp(state.defaultZoom, state.min, state.max);
|
||||
var canvas = rendererCanvas(renderer);
|
||||
if (!canvas) return clamp(state.defaultZoom, state.min, state.max);
|
||||
var cw = canvas.width || canvas.offsetWidth || 1;
|
||||
var ch = canvas.height || canvas.offsetHeight || 1;
|
||||
var w = host.clientWidth || host.offsetWidth || 640;
|
||||
var h = host.clientHeight || host.offsetHeight || 400;
|
||||
return clamp(Math.min(w / cw, h / ch) * (Number(ratio) || state.fitRatio || 0.72), state.min, state.max);
|
||||
}
|
||||
function setZoom(next, manual) {
|
||||
if (state.destroyed) return;
|
||||
if (manual) state.autoFit = false;
|
||||
state.zoom = clamp(Number(next) || 1, state.min, state.max);
|
||||
updateReadout();
|
||||
RK.updateCamera(host, state.renderer);
|
||||
}
|
||||
function reset() {
|
||||
if (state.destroyed) return;
|
||||
state.panX = 0;
|
||||
state.panY = 0;
|
||||
state.autoFit = !!(state.fitRatio && state.renderer);
|
||||
setZoom(state.autoFit ? fitZoom(state.renderer) : state.defaultZoom);
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
if (state.destroyed) return;
|
||||
e.preventDefault();
|
||||
setZoom(state.zoom * Math.exp(-e.deltaY * 0.0015), true);
|
||||
}
|
||||
function onDoubleClick(e) {
|
||||
if (state.destroyed) return;
|
||||
if (e.target && e.target.closest && e.target.closest('.rk-camera-controls')) return;
|
||||
reset();
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
if (state.destroyed) return;
|
||||
if (e.target && e.target.closest && e.target.closest('.rk-camera-controls')) return;
|
||||
state.pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
|
||||
state.dragId = e.pointerId;
|
||||
host.style.cursor = 'grabbing';
|
||||
try { host.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
}
|
||||
function onPointerMove(e) {
|
||||
if (state.destroyed) return;
|
||||
var prev = state.pointers[e.pointerId];
|
||||
if (!prev) return;
|
||||
state.pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
|
||||
var ids = Object.keys(state.pointers);
|
||||
if (ids.length > 1) {
|
||||
var a = state.pointers[ids[0]], b = state.pointers[ids[1]];
|
||||
var dist = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
if (state.pinchDistance) setZoom(state.zoom * (dist / state.pinchDistance));
|
||||
state.pinchDistance = dist;
|
||||
} else {
|
||||
state.autoFit = false;
|
||||
state.panX += e.clientX - prev.x;
|
||||
state.panY += e.clientY - prev.y;
|
||||
RK.updateCamera(host, state.renderer);
|
||||
}
|
||||
}
|
||||
function releasePointer(e) {
|
||||
if (state.destroyed) return;
|
||||
delete state.pointers[e.pointerId];
|
||||
state.pinchDistance = null;
|
||||
if (!Object.keys(state.pointers).length) host.style.cursor = 'grab';
|
||||
}
|
||||
listen(host, 'wheel', onWheel, { passive: false });
|
||||
listen(host, 'dblclick', onDoubleClick);
|
||||
listen(host, 'pointerdown', onPointerDown);
|
||||
listen(host, 'pointermove', onPointerMove);
|
||||
listen(host, 'pointerup', releasePointer);
|
||||
listen(host, 'pointercancel', releasePointer);
|
||||
|
||||
if (opts.controls !== false && root.document) {
|
||||
var controls = root.document.createElement('div');
|
||||
controls.className = 'rk-camera-controls';
|
||||
controls.setAttribute('aria-label', 'Camera controls');
|
||||
controls.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:20;display:flex;' +
|
||||
'align-items:center;gap:4px;padding:4px;background:rgba(4,7,10,.82);border:1px solid rgba(255,255,255,.2);' +
|
||||
'border-radius:7px;box-shadow:0 4px 18px rgba(0,0,0,.45);font:12px ui-monospace,monospace;';
|
||||
function button(label, title, fn) {
|
||||
var b = root.document.createElement('button');
|
||||
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;';
|
||||
listen(b, 'click', function (e) { e.stopPropagation(); fn(); });
|
||||
controls.appendChild(b);
|
||||
}
|
||||
button('−', 'Zoom out', function () { setZoom(state.zoom / 1.18, true); });
|
||||
state.readout = root.document.createElement('span');
|
||||
state.readout.style.cssText = 'min-width:42px;text-align:center;color:#a7f3d0;';
|
||||
controls.appendChild(state.readout);
|
||||
button('+', 'Zoom in', function () { setZoom(state.zoom * 1.18, true); });
|
||||
button('⌂', 'Reset camera', reset);
|
||||
host.appendChild(controls);
|
||||
state.controls = controls;
|
||||
}
|
||||
updateReadout();
|
||||
|
||||
if (typeof root.ResizeObserver === 'function') {
|
||||
state.observer = new root.ResizeObserver(function () {
|
||||
if (state.destroyed) return;
|
||||
if (state.autoFit) setZoom(fitZoom(state.renderer));
|
||||
else RK.updateCamera(host, state.renderer);
|
||||
});
|
||||
state.observer.observe(host);
|
||||
}
|
||||
state.api = {
|
||||
update: function (renderer) {
|
||||
if (!state.destroyed) RK.updateCamera(host, renderer);
|
||||
},
|
||||
setZoom: setZoom,
|
||||
setDefaultZoom: function (zoom) {
|
||||
if (!state.destroyed) state.defaultZoom = Number(zoom) || state.defaultZoom;
|
||||
},
|
||||
fit: function (renderer, ratio) {
|
||||
if (state.destroyed) return;
|
||||
state.renderer = renderer || state.renderer;
|
||||
if (ratio != null) state.fitRatio = Number(ratio) || state.fitRatio;
|
||||
state.panX = 0;
|
||||
state.panY = 0;
|
||||
state.autoFit = !!state.renderer;
|
||||
setZoom(fitZoom(state.renderer));
|
||||
},
|
||||
reset: reset,
|
||||
getZoom: function () { return state.zoom; },
|
||||
destroy: function () {
|
||||
if (state.destroyed) return;
|
||||
state.destroyed = true;
|
||||
state.listeners.splice(0).forEach(function (entry) {
|
||||
if (entry.target && typeof entry.target.removeEventListener === 'function') {
|
||||
entry.target.removeEventListener(entry.type, entry.handler, entry.options);
|
||||
}
|
||||
});
|
||||
Object.keys(state.pointers).forEach(function (pointerId) {
|
||||
try {
|
||||
if (typeof host.releasePointerCapture === 'function') {
|
||||
host.releasePointerCapture(Number(pointerId));
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
state.pointers = {};
|
||||
state.pinchDistance = null;
|
||||
host.style.cursor = 'grab';
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
state.observer = null;
|
||||
}
|
||||
if (state.controls && state.controls.parentNode) state.controls.parentNode.removeChild(state.controls);
|
||||
state.controls = null;
|
||||
state.readout = null;
|
||||
state.renderer = null;
|
||||
if (host._rkCamera === state) delete host._rkCamera;
|
||||
}
|
||||
};
|
||||
return state.api;
|
||||
};
|
||||
|
||||
RK.updateCamera = function (host, renderer) {
|
||||
var state = host && host._rkCamera;
|
||||
if (!state || !renderer) return;
|
||||
state.renderer = renderer;
|
||||
if (renderer.name === '3d') {
|
||||
if (typeof renderer.setZoom === 'function') renderer.setZoom(state.zoom);
|
||||
if (typeof renderer.setPan === 'function') renderer.setPan(state.panX, state.panY);
|
||||
return;
|
||||
}
|
||||
var canvas = rendererCanvas(renderer);
|
||||
if (!canvas) return;
|
||||
var cw = canvas.width || canvas.offsetWidth || 1;
|
||||
var ch = canvas.height || canvas.offsetHeight || 1;
|
||||
var w = host.clientWidth || host.offsetWidth || 640;
|
||||
var h = host.clientHeight || host.offsetHeight || 400;
|
||||
var focus = renderer.focusPoint || { x: cw / 2, y: ch / 2 };
|
||||
var z = state.zoom;
|
||||
var tx = w / 2 - focus.x * z + state.panX;
|
||||
var ty = h / 2 - focus.y * z + state.panY;
|
||||
var sw = cw * z, sh = ch * z;
|
||||
tx = sw <= w ? (w - sw) / 2 : clamp(tx, w - sw, 0);
|
||||
ty = sh <= h ? (h - sh) / 2 : clamp(ty, h - sh, 0);
|
||||
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.maxWidth = 'none';
|
||||
canvas.style.maxHeight = 'none';
|
||||
canvas.style.transformOrigin = '0 0';
|
||||
canvas.style.imageRendering = 'pixelated';
|
||||
canvas.style.transition = 'transform 110ms ease-out';
|
||||
canvas.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + z + ')';
|
||||
};
|
||||
})(window);
|
||||
|
||||
Reference in New Issue
Block a user