Files
wownerogue/html/js/core/singlePlayerAvatar.js
T
jwinterm 1325b49b42 feat(render): single-player dungeon renders through the render kit (packs in gameplay)
The migration that makes the pack ladder pay off IN the dungeon, not just the tavern.
The SP game now draws via RK (Tiled/ASCII/Iso/3D + unlocked packs) instead of the
legacy ROT display.

- Adapter: sceneFromGameState now consumes the CLIENT state shape (sparse {y:{x:v}}
  tile maps, not just server arrays) via maxDims(). Verified + unit-tested.
- Bug fix: RK.sceneFromGameState was never actually exposed (only RK.scene.*), so
  matchClient + tavern spectator were silently no-op-ing. Aliased onto RK.
- Bridge (spGameRenderer.js, RK.SPGame): mounts an RK renderer in a dedicated host
  inside #game-display, toggles the legacy ROT canvases, zoom-enabled. Game._draw
  GameScreen prefers it and FALLS BACK to RenderEngine on any failure, so the live
  game can never blank. ROT still draws the splash/win/lose text screens.
- Picker (spRenderPicker.js): homepage mode + pack selector, gated by entitlements
  (free = Tiled/Original + ASCII; ladder unlocks Iso/3D/packs), rebuilds on identity.
- index.html loads the render-kit renderers + the bridge/picker.

Bundled with the catalog ladder (migration 028): free Original bare pack, ladder
1/5/10/20/40/50. Deploying makes the free tavern bare (by design) AND the dungeon
render-kit-ed. v1 renders the whole explored level + zoom (player-centered camera is
the next polish). Suite 62/435 green; adapter smoke-verified.
2026-07-12 10:14:56 -04:00

238 lines
11 KiB
JavaScript

// SinglePlayerAvatar stores the selected identity for the legacy single-player page and manages
// the overlay canvas used by old ROT.js screens. It delegates visual decisions to RK.avatarVisuals.
(function (root) {
'use strict';
var DEFAULT_APPEARANCE = { avatar: 'default', tint: 'none', equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' } };
function normalize(input) {
if (root.RK && root.RK.normalizeAppearance) return root.RK.normalizeAppearance(input || DEFAULT_APPEARANCE);
return DEFAULT_APPEARANCE;
}
function requestRedraw() {
if (root.Game && root.Game._gameActive && root.Game._drawGameScreen) {
root.Game._drawGameScreen();
} else if (root.ScreenManager && root.DisplayManager && root.DisplayManager.ensureDisplay && root.DisplayManager.ensureDisplay()) {
if (root.ScreenManager.isShowingWaitingScreen && root.ScreenManager.isShowingWaitingScreen() && root.ScreenManager.drawWaitingScreen) {
root.ScreenManager.drawWaitingScreen(true);
} else if (root.ScreenManager.drawWelcomeScreen) {
root.ScreenManager.drawWelcomeScreen();
}
}
}
var SinglePlayerAvatar = {
_appearance: null,
_overlay: null,
_ctx: null,
_lastWorld: null,
_facing: 'down',
_loading: {},
_initialized: false,
init: function () {
if (this._initialized) return;
this._initialized = true;
var identity = root.RK && root.RK.loadIdentity ? root.RK.loadIdentity() : null;
this.setAppearance(identity && identity.appearance ? identity.appearance : DEFAULT_APPEARANCE, { save: false, redraw: false });
this._bindButton();
this.updateButton();
},
_bindButton: function () {
var self = this;
var btn = root.document && root.document.getElementById('characterButton');
if (!btn || btn._rkBound) return;
btn._rkBound = true;
btn.addEventListener('click', function () {
if (!root.RK || !root.RK.openCustomize) return;
root.RK.openCustomize(self._appearance || DEFAULT_APPEARANCE, function (appearance) {
var normalized = normalize(appearance);
self.setAppearance(normalized, { save: true, redraw: true });
if (root.socket && root.socket.emit) {
root.socket.emit('identity:update', { appearance: normalized });
}
}, { projection: 'topdown' });
});
},
setAppearance: function (appearance, opts) {
opts = opts || {};
this._appearance = normalize(appearance);
if (opts.save !== false && root.RK && root.RK.saveIdentity) root.RK.saveIdentity(this._appearance);
this.updateButton();
if (opts.redraw !== false) requestRedraw();
},
applyIdentity: function (data) {
if (!data) return;
if (data.entitlements && root.RK && root.RK.setEntitlementSnapshot) {
root.RK.setEntitlementSnapshot(data.entitlements);
if (root.RK.rebuildRenderPicker) root.RK.rebuildRenderPicker();
}
if (data.appearance) this.setAppearance(data.appearance, { save: true, redraw: true });
else this.updateButton();
},
applyEntitlements: function (data) {
if (data && root.RK && root.RK.setEntitlementSnapshot) {
root.RK.setEntitlementSnapshot(data);
this.updateButton();
}
},
updateButton: function () {
var btn = root.document && root.document.getElementById('characterButton');
var label = root.document && root.document.getElementById('characterButtonLabel');
if (!btn || !label) return;
if (!root.RK || !root.RK.appearance) {
label.textContent = 'CHARACTER';
return;
}
var info = root.RK.appearance(this._appearance || DEFAULT_APPEARANCE);
// Show the chosen character's name, but keep it clearly a customize control (the leading
// 🎭 + this title) so it doesn't read like a mode/role button.
label.textContent = (info && info.label ? info.label : 'CHARACTER').toUpperCase();
btn.title = 'Create / customize your character'
+ (info && info.label ? ' — currently ' + info.label : '')
+ (info && info.premium ? ' (premium)' : '');
},
_baseCanvas: function () {
if (!root.DisplayManager || !root.DisplayManager.getDisplay()) return null;
var display = root.DisplayManager.getDisplay();
return display && display.getContainer ? display.getContainer() : null;
},
_syncOverlay: function () {
var base = this._baseCanvas();
var host = root.document && root.document.getElementById('game-display');
if (!base || !host) return null;
if (!this._overlay) {
this._overlay = root.document.createElement('canvas');
this._overlay.className = 'single-player-avatar-overlay';
this._overlay.style.position = 'absolute';
this._overlay.style.pointerEvents = 'none';
this._overlay.style.zIndex = '6';
this._overlay.style.imageRendering = 'pixelated';
host.appendChild(this._overlay);
this._ctx = this._overlay.getContext('2d');
}
if (this._overlay.width !== base.width) this._overlay.width = base.width;
if (this._overlay.height !== base.height) this._overlay.height = base.height;
this._overlay.style.left = base.offsetLeft + 'px';
this._overlay.style.top = base.offsetTop + 'px';
this._overlay.style.width = base.offsetWidth + 'px';
this._overlay.style.height = base.offsetHeight + 'px';
this._overlay.style.display = 'block';
return this._ctx;
},
clearOverlay: function () {
if (this._ctx && this._overlay) this._ctx.clearRect(0, 0, this._overlay.width, this._overlay.height);
},
currentAppearance: function () {
return this._appearance || normalize(DEFAULT_APPEARANCE);
},
resolveVisual: function (opts) {
opts = opts || {};
if (!root.RK || !root.RK.avatarVisuals) {
return { appearance: this.currentAppearance(), kind: 'legacy', fallbackTile: opts.fallbackTile || '@' };
}
return root.RK.avatarVisuals.resolve(this.currentAppearance(), opts);
},
_ensureVisual: function (visual) {
if (!root.RK || !root.RK.avatarVisuals || !visual || !visual.canvas) return false;
return root.RK.avatarVisuals.ensureCanvasReady(visual, requestRedraw);
},
canDrawPlayer: function () {
return this._ensureVisual(this.resolveVisual({ projection: 'legacy-rot', context: 'dungeon-player' }));
},
_updateFacing: function (player) {
if (!player) return this._facing;
if (this._lastWorld) {
var dx = player.x - this._lastWorld.x;
var dy = player.y - this._lastWorld.y;
if (dx < 0) this._facing = 'left';
else if (dx > 0) this._facing = 'right';
else if (dy < 0) this._facing = 'up';
else if (dy > 0) this._facing = 'down';
}
this._lastWorld = { x: player.x, y: player.y };
return this._facing;
},
drawPlayer: function (gameState, viewport) {
var visual = this.resolveVisual({ projection: 'legacy-rot', context: 'dungeon-player' });
if (!gameState || !gameState.player || !viewport || !this._ensureVisual(visual)) {
this.clearOverlay();
return false;
}
var ctx = this._syncOverlay();
if (!ctx) return false;
this.clearOverlay();
var player = gameState.player;
var facing = this._updateFacing(player);
var drew = root.RK.avatarVisuals.drawTopdownWorld(ctx, visual, {
id: 'single-player',
x: player.x,
y: player.y,
facing: facing
}, viewport, { onReady: requestRedraw });
// After the avatar is drawn, sync the overlay FX layer to the same base
// canvas and let its warm torch light track the player cell. Fully guarded:
// no-op when the FX module is absent.
this._syncFX(viewport);
return drew;
},
// Attach/sync the FX overlay to the same base canvas used by the avatar overlay
// (mirrors _syncOverlay's sizing) and drive the torch light at the player cell.
_syncFX: function (viewport) {
if (!root.FX || !viewport) return;
var base = this._baseCanvas();
if (!base) return;
try {
root.FX.attach(base);
var cell = viewport.cell || (root.options && root.options.tileWidth) || 32;
var px = viewport.screenX * cell + cell / 2;
var py = viewport.screenY * cell + cell / 2;
root.FX.renderLighting(px, py, cell);
} catch (_) { /* FX is purely additive — never let it break the avatar */ }
},
drawLegendIcon: function (tileX, tileY, opts) {
opts = opts || {};
var visual = this.resolveVisual({ projection: 'legacy-rot', context: 'legend', fallbackTile: opts.fallbackTile || '@2' });
if (!this._ensureVisual(visual)) return false;
var ctx = this._syncOverlay();
if (!ctx || !root.RK || !root.RK.avatarVisuals) return false;
var cell = opts.cell || (root.options && root.options.tileWidth) || 32;
var cols = opts.cols || 2;
return root.RK.avatarVisuals.drawIcon(ctx, visual, {
x: tileX * cell,
y: tileY * cell,
w: cols * cell,
h: cell
}, {
context: 'legend',
scale: 0.78,
offsetY: Math.max(1, Math.round(cell * 0.08)),
onReady: requestRedraw
});
}
};
root.SinglePlayerAvatar = SinglePlayerAvatar;
if (typeof module !== 'undefined' && module.exports) module.exports = SinglePlayerAvatar;
})(typeof window !== 'undefined' ? window : globalThis);