diff --git a/html/index.html b/html/index.html
index 10063f1..fd9047d 100644
--- a/html/index.html
+++ b/html/index.html
@@ -76,6 +76,7 @@
+
diff --git a/html/js/core/singlePlayerAvatar.js b/html/js/core/singlePlayerAvatar.js
index 0573da3..855a128 100644
--- a/html/js/core/singlePlayerAvatar.js
+++ b/html/js/core/singlePlayerAvatar.js
@@ -1,6 +1,5 @@
-// SinglePlayerAvatar bridges the shared character identity/render kit into the legacy ROT.js
-// dungeon display. The dungeon still draws through ROT; this module owns a sprite overlay that
-// can be cleared safely back to the classic @ tile whenever assets or identity are unavailable.
+// 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';
@@ -11,16 +10,15 @@
return DEFAULT_APPEARANCE;
}
- function baseKind(appearance) {
- if (!appearance || !root.RK) return 'color';
- if (root.RK.isChar && root.RK.isChar(appearance.avatar)) return 'char';
- if (root.RK.isSkin && root.RK.isSkin(appearance.avatar)) return 'skin';
- return 'color';
- }
-
- function requestGameRedraw() {
+ 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();
+ }
}
}
@@ -64,7 +62,7 @@
this._appearance = normalize(appearance);
if (opts.save !== false && root.RK && root.RK.saveIdentity) root.RK.saveIdentity(this._appearance);
this.updateButton();
- if (opts.redraw !== false) requestGameRedraw();
+ if (opts.redraw !== false) requestRedraw();
},
applyIdentity: function (data) {
@@ -136,41 +134,21 @@
return this._appearance || normalize(DEFAULT_APPEARANCE);
},
- _ensureAssets: function (appearance) {
- var kind = baseKind(appearance);
- var self = this;
- if (kind === 'char') {
- if (root.RK.charAtlas && root.RK.charAtlas()) return true;
- if (root.RK.loadCharAtlas && !this._loading.char) {
- this._loading.char = true;
- root.RK.loadCharAtlas(function () {
- self._loading.char = false;
- requestGameRedraw();
- });
- }
- return false;
+ resolveVisual: function (opts) {
+ opts = opts || {};
+ if (!root.RK || !root.RK.avatarVisuals) {
+ return { appearance: this.currentAppearance(), kind: 'legacy', fallbackTile: opts.fallbackTile || '@' };
}
- if (kind === 'skin') {
- if (root.RK.skinSheet && root.RK.skinSheet(appearance.avatar)) return true;
- if (root.RK.loadSkin && !this._loading[appearance.avatar]) {
- this._loading[appearance.avatar] = true;
- var rec = root.RK.loadSkin(appearance.avatar);
- if (rec && rec.cbs) rec.cbs.push(function () {
- self._loading[appearance.avatar] = false;
- requestGameRedraw();
- });
- }
- return false;
- }
- return false;
+ 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 () {
- if (!root.RK) return false;
- var appearance = this.currentAppearance();
- var kind = baseKind(appearance);
- if (kind !== 'char' && kind !== 'skin') return false;
- return this._ensureAssets(appearance);
+ return this._ensureVisual(this.resolveVisual({ projection: 'legacy-rot', context: 'dungeon-player' }));
},
_updateFacing: function (player) {
@@ -188,7 +166,8 @@
},
drawPlayer: function (gameState, viewport) {
- if (!gameState || !gameState.player || !viewport || !this.canDrawPlayer()) {
+ var visual = this.resolveVisual({ projection: 'legacy-rot', context: 'dungeon-player' });
+ if (!gameState || !gameState.player || !viewport || !this._ensureVisual(visual)) {
this.clearOverlay();
return false;
}
@@ -196,62 +175,30 @@
if (!ctx) return false;
this.clearOverlay();
- var appearance = this.currentAppearance();
- var kind = baseKind(appearance);
var player = gameState.player;
var facing = this._updateFacing(player);
- var cell = viewport.cell || (root.options && root.options.tileWidth) || 32;
- var now = Date.now();
-
- if (kind === 'char') return this._drawChar(ctx, appearance, player, viewport.screenX, viewport.screenY, cell, facing, now);
- if (kind === 'skin') return this._drawSkin(ctx, appearance, player, viewport.screenX, viewport.screenY, cell, facing, now);
- return false;
+ return root.RK.avatarVisuals.drawTopdownWorld(ctx, visual, {
+ id: 'single-player',
+ x: player.x,
+ y: player.y,
+ facing: facing
+ }, viewport, { onReady: requestRedraw });
},
- _drawChar: function (ctx, appearance, player, sx, sy, cell, facing, now) {
- var ch = root.RK.CHARS && root.RK.CHARS[appearance.avatar];
- if (!ch || !root.RK.charFrame || !root.RK.drawCharTileCanvas) return false;
- var e = { id: 'single-player', x: player.x, y: player.y, facing: facing };
- var frame = root.RK.charFrame(ch, e, now);
- var dw = cell * 1.8;
- var dh = dw * frame.squash;
- var cx = sx * cell + cell / 2;
- var dy = (sy * cell + cell) - dh + frame.bob;
- var ap = root.RK.charAppearance({ avatar: appearance.avatar, appearance: appearance });
-
- ctx.imageSmoothingEnabled = false;
- function drawComposite(dx, dy2) {
- root.RK.drawCharTileCanvas(ctx, ch.frame, ap.tint, dx, dy2, dw, dh, ap.colors, 'base');
- root.RK.charOverlayParts(ap).forEach(function (part) {
- root.RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy2, dw, dh, part.colorable ? ap.colors : null, part.slot);
- });
- }
- if (frame.flip) {
- ctx.save();
- ctx.translate(cx + dw / 2, dy);
- ctx.scale(-1, 1);
- drawComposite(0, 0);
- ctx.restore();
- } else {
- drawComposite(cx - dw / 2, dy);
- }
- return true;
- },
-
- _drawSkin: function (ctx, appearance, player, sx, sy, cell, facing, now) {
- var rec = root.RK.skinSheet && root.RK.skinSheet(appearance.avatar);
- var skin = root.RK.SKINS && root.RK.SKINS[appearance.avatar];
- if (!rec || !skin || !root.RK.skinFrame) return false;
- var e = { id: 'single-player-skin', x: player.x, y: player.y, facing: facing };
- var fr = root.RK.skinFrame(skin, e, now);
- var dh = cell * (skin.scale || 1.7);
- var dw = dh * (skin.frameW / skin.frameH);
- var cx = sx * cell + cell / 2;
- var dx = cx - dw / 2;
- var dy = (sy * cell + cell) - dh;
- ctx.imageSmoothingEnabled = true;
- ctx.drawImage(rec.img, fr.col * skin.frameW, fr.row * skin.frameH, skin.frameW, skin.frameH, dx, dy, dw, dh);
- return true;
+ 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
+ }, { onReady: requestRedraw });
}
};
diff --git a/html/js/display/screenManager.js b/html/js/display/screenManager.js
index e62ece7..462c615 100644
--- a/html/js/display/screenManager.js
+++ b/html/js/display/screenManager.js
@@ -105,23 +105,38 @@ var ScreenManager = {
? GameTiles.getTreasureTile()
: (this._cryptoType === 'XMR' ? '$M' : '$W');
const legendItems = [
- { tile: "@2", text: "This is you" },
+ { tile: "@2", text: "This is you", role: "player", span: 2 },
{ tile: ">", text: " Escape the dungeon" },
{ tile: "~", text: " Avoid the monster" },
{ tile: treasureTile, text: "Secure the bag" }
];
+ if (window.SinglePlayerAvatar && window.SinglePlayerAvatar.clearOverlay) {
+ window.SinglePlayerAvatar.clearOverlay();
+ }
+
const centerX = Math.floor(this._screenWidth / 2) - 10;
for (let item of legendItems) {
let x = centerX;
+ const span = item.span || item.tile.length || 1;
+ let iconDrawn = false;
- // Draw the special tile
- if (window.options?.tileMap?.hasOwnProperty(item.tile)) {
+ if (item.role === "player" && window.SinglePlayerAvatar && window.SinglePlayerAvatar.drawLegendIcon) {
+ iconDrawn = window.SinglePlayerAvatar.drawLegendIcon(x, y, {
+ fallbackTile: item.tile,
+ cols: span
+ });
+ }
+
+ // Draw the special tile when the resolved avatar visual is not canvas-ready.
+ if (!iconDrawn && window.options?.tileMap?.hasOwnProperty(item.tile)) {
display.draw(x, y, item.tile, "transparent", "transparent");
- x += item.tile.length;
- } else {
+ x += span;
+ } else if (!iconDrawn) {
display.draw(x, y, "?", "transparent", "transparent");
x += 1;
+ } else {
+ x += span;
}
// Draw the descriptive text
diff --git a/html/js/render/avatarVisuals.js b/html/js/render/avatarVisuals.js
new file mode 100644
index 0000000..de2f8c2
--- /dev/null
+++ b/html/js/render/avatarVisuals.js
@@ -0,0 +1,197 @@
+// AvatarVisuals is the render-kit resolver for turning a saved appearance identity into a
+// drawable visual for a specific projection/context. Legacy ROT screens can use fallback tiles;
+// canvas-capable renderers can draw the same identity as a composed character or premium skin.
+(function (root) {
+ 'use strict';
+
+ var RK = root.RK = root.RK || {};
+ var DEFAULT_EQUIPMENT = { body: 'none', head: 'none', shield: 'none', weapon: 'none' };
+ var DEFAULT_APPEARANCE = { avatar: 'default', tint: 'none', equipment: DEFAULT_EQUIPMENT };
+ var loading = {};
+
+ function normalize(appearance) {
+ if (RK.normalizeAppearance) return RK.normalizeAppearance(appearance || DEFAULT_APPEARANCE);
+ return DEFAULT_APPEARANCE;
+ }
+
+ function kindFor(appearance) {
+ if (!appearance) return 'legacy';
+ if (RK.isChar && RK.isChar(appearance.avatar)) return 'char';
+ if (RK.isSkin && RK.isSkin(appearance.avatar)) return 'skin';
+ if (appearance.avatar && /^kenney-/.test(appearance.avatar)) return 'model3d';
+ return 'legacy';
+ }
+
+ function fallbackTile(opts) {
+ opts = opts || {};
+ if (opts.fallbackTile) return opts.fallbackTile;
+ if (root.GameTiles && root.GameTiles.getPlayerTile) return root.GameTiles.getPlayerTile();
+ return '@';
+ }
+
+ function labelFor(appearance) {
+ if (RK.appearance) {
+ var info = RK.appearance(appearance);
+ if (info && info.label) return info.label;
+ }
+ return 'Character';
+ }
+
+ function resolve(appearance, opts) {
+ opts = opts || {};
+ var ap = normalize(appearance);
+ var kind = kindFor(ap);
+ return {
+ appearance: ap,
+ kind: kind,
+ projection: opts.projection || 'legacy-rot',
+ context: opts.context || 'player',
+ label: labelFor(ap),
+ fallbackTile: fallbackTile(opts),
+ canvas: kind === 'char' || kind === 'skin'
+ };
+ }
+
+ function rememberLoad(key, start, onReady) {
+ if (loading[key]) {
+ if (onReady) loading[key].push(onReady);
+ return;
+ }
+ loading[key] = onReady ? [onReady] : [];
+ start(function () {
+ var cbs = loading[key] || [];
+ delete loading[key];
+ cbs.forEach(function (cb) { try { cb(); } catch (_) { /* ignore */ } });
+ });
+ }
+
+ function ensureCanvasReady(visual, onReady) {
+ visual = visual && visual.appearance ? visual : resolve(visual);
+ if (visual.kind === 'char') {
+ if (RK.charAtlas && RK.charAtlas()) return true;
+ if (!RK.loadCharAtlas) return false;
+ rememberLoad('char-atlas', function (done) { RK.loadCharAtlas(done); }, onReady);
+ return false;
+ }
+ if (visual.kind === 'skin') {
+ if (RK.skinSheet && RK.skinSheet(visual.appearance.avatar)) return true;
+ if (!RK.loadSkin) return false;
+ rememberLoad('skin:' + visual.appearance.avatar, function (done) {
+ var rec = RK.loadSkin(visual.appearance.avatar);
+ if (rec && rec.cbs) rec.cbs.push(done);
+ }, onReady);
+ return false;
+ }
+ return false;
+ }
+
+ function drawCharIcon(ctx, visual, x, y, size) {
+ if (!RK.drawCharCompositeCanvas) return false;
+ return RK.drawCharCompositeCanvas(ctx, visual.appearance, x, y, size);
+ }
+
+ function drawSkinIcon(ctx, visual, x, y, size) {
+ var rec = RK.skinSheet && RK.skinSheet(visual.appearance.avatar);
+ var skin = RK.SKINS && RK.SKINS[visual.appearance.avatar];
+ if (!rec || !skin) return false;
+ var row = (skin.dirRows && skin.dirRows.down) || 0;
+ var dh = size;
+ var dw = dh * (skin.frameW / skin.frameH);
+ ctx.imageSmoothingEnabled = true;
+ ctx.drawImage(rec.img, 0, row * skin.frameH, skin.frameW, skin.frameH, x + (size - dw) / 2, y, dw, dh);
+ return true;
+ }
+
+ function drawIcon(ctx, visual, rect, opts) {
+ opts = opts || {};
+ visual = visual && visual.appearance ? visual : resolve(visual, opts);
+ if (!ctx || !rect || !ensureCanvasReady(visual, opts.onReady)) return false;
+ var size = Math.min(rect.w || rect.size || 32, rect.h || rect.size || 32);
+ var x = rect.x + Math.max(0, ((rect.w || size) - size) / 2);
+ var y = rect.y + Math.max(0, ((rect.h || size) - size) / 2);
+ ctx.clearRect(rect.x, rect.y, rect.w || size, rect.h || size);
+ if (visual.kind === 'char') return drawCharIcon(ctx, visual, x, y, size);
+ if (visual.kind === 'skin') return drawSkinIcon(ctx, visual, x, y, size);
+ return false;
+ }
+
+ function drawTopdownChar(ctx, visual, entity, viewport, opts) {
+ opts = opts || {};
+ var ch = RK.CHARS && RK.CHARS[visual.appearance.avatar];
+ if (!ch || !RK.charFrame || !RK.drawCharTileCanvas) return false;
+ var cell = viewport.cell || 32;
+ var e = {
+ id: entity.id || 'avatar',
+ x: entity.x || 0,
+ y: entity.y || 0,
+ facing: entity.facing || 'down'
+ };
+ var frame = RK.charFrame(ch, e, opts.now || Date.now());
+ var dw = cell * (opts.scale || 1.8);
+ var dh = dw * frame.squash;
+ var cx = viewport.screenX * cell + cell / 2;
+ var dy = (viewport.screenY * cell + cell) - dh + frame.bob;
+ var ap = RK.charAppearance({ avatar: visual.appearance.avatar, appearance: visual.appearance });
+
+ ctx.imageSmoothingEnabled = false;
+ function drawComposite(dx, dy2) {
+ RK.drawCharTileCanvas(ctx, ch.frame, ap.tint, dx, dy2, dw, dh, ap.colors, 'base');
+ RK.charOverlayParts(ap).forEach(function (part) {
+ RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy2, dw, dh, part.colorable ? ap.colors : null, part.slot);
+ });
+ }
+ if (frame.flip) {
+ ctx.save();
+ ctx.translate(cx + dw / 2, dy);
+ ctx.scale(-1, 1);
+ drawComposite(0, 0);
+ ctx.restore();
+ } else {
+ drawComposite(cx - dw / 2, dy);
+ }
+ return true;
+ }
+
+ function drawTopdownSkin(ctx, visual, entity, viewport, opts) {
+ opts = opts || {};
+ var rec = RK.skinSheet && RK.skinSheet(visual.appearance.avatar);
+ var skin = RK.SKINS && RK.SKINS[visual.appearance.avatar];
+ if (!rec || !skin || !RK.skinFrame) return false;
+ var cell = viewport.cell || 32;
+ var e = {
+ id: entity.id || 'avatar-skin',
+ x: entity.x || 0,
+ y: entity.y || 0,
+ facing: entity.facing || 'down'
+ };
+ var frame = RK.skinFrame(skin, e, opts.now || Date.now());
+ var dh = cell * (skin.scale || 1.7);
+ var dw = dh * (skin.frameW / skin.frameH);
+ var cx = viewport.screenX * cell + cell / 2;
+ var dx = cx - dw / 2;
+ var dy = (viewport.screenY * cell + cell) - dh;
+ ctx.imageSmoothingEnabled = true;
+ ctx.drawImage(rec.img, frame.col * skin.frameW, frame.row * skin.frameH, skin.frameW, skin.frameH, dx, dy, dw, dh);
+ return true;
+ }
+
+ function drawTopdownWorld(ctx, visual, entity, viewport, opts) {
+ opts = opts || {};
+ visual = visual && visual.appearance ? visual : resolve(visual, opts);
+ if (!ctx || !entity || !viewport || !ensureCanvasReady(visual, opts.onReady)) return false;
+ if (visual.kind === 'char') return drawTopdownChar(ctx, visual, entity, viewport, opts);
+ if (visual.kind === 'skin') return drawTopdownSkin(ctx, visual, entity, viewport, opts);
+ return false;
+ }
+
+ RK.avatarVisuals = {
+ DEFAULT_APPEARANCE: DEFAULT_APPEARANCE,
+ normalize: normalize,
+ kindFor: kindFor,
+ resolve: resolve,
+ ensureCanvasReady: ensureCanvasReady,
+ drawIcon: drawIcon,
+ drawTopdownWorld: drawTopdownWorld
+ };
+ RK.resolveAvatarVisual = resolve;
+})(window);
diff --git a/html/tavern.html b/html/tavern.html
index ca9ab22..3aa5c14 100644
--- a/html/tavern.html
+++ b/html/tavern.html
@@ -13,6 +13,7 @@
+
diff --git a/test/singlePlayerAvatar.test.js b/test/singlePlayerAvatar.test.js
index 49d3835..d9877a4 100644
--- a/test/singlePlayerAvatar.test.js
+++ b/test/singlePlayerAvatar.test.js
@@ -5,14 +5,16 @@ describe('single-player character identity wiring', () => {
const indexHtml = fs.readFileSync(path.join(__dirname, '../html/index.html'), 'utf8');
const socketHandlers = fs.readFileSync(path.join(__dirname, '../html/js/network/socketHandlers.js'), 'utf8');
const renderEngine = fs.readFileSync(path.join(__dirname, '../html/js/display/renderEngine.js'), 'utf8');
+ const screenManager = fs.readFileSync(path.join(__dirname, '../html/js/display/screenManager.js'), 'utf8');
- test('main page loads shared character renderer dependencies before the overlay bridge', () => {
+ test('main page loads the avatar visual resolver before the overlay bridge', () => {
const order = [
'js/render/atlas.js',
'js/render/skins.js',
'js/render/charSprites.js',
'js/render/assetPacks.js',
'js/render/charCustomize.js',
+ 'js/render/avatarVisuals.js',
'js/core/singlePlayerAvatar.js'
].map(src => indexHtml.indexOf(src));
@@ -32,4 +34,10 @@ describe('single-player character identity wiring', () => {
expect(renderEngine).toContain('if (!avatarOverlayReady)');
expect(renderEngine).toContain('SinglePlayerAvatar.drawPlayer');
});
+
+ test('welcome legend resolves the player icon through the avatar bridge', () => {
+ expect(screenManager).toContain('role: "player"');
+ expect(screenManager).toContain('SinglePlayerAvatar.clearOverlay');
+ expect(screenManager).toContain('SinglePlayerAvatar.drawLegendIcon');
+ });
});