refactor: resolve avatar visuals through render kit

This commit is contained in:
jwinterm
2026-07-06 03:41:11 -04:00
parent be780c17e6
commit 3195386ba1
6 changed files with 271 additions and 102 deletions
+1
View File
@@ -76,6 +76,7 @@
<script src="js/render/charSprites.js"></script>
<script src="js/render/assetPacks.js"></script>
<script src="js/render/charCustomize.js"></script>
<script src="js/render/avatarVisuals.js"></script>
<script src="js/core/singlePlayerAvatar.js"></script>
<!-- Other modules -->
+43 -96
View File
@@ -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 });
}
};
+20 -5
View File
@@ -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
+197
View File
@@ -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);
+1
View File
@@ -13,6 +13,7 @@
<script src="js/render/atlas.js"></script>
<script src="js/render/charSprites.js"></script>
<script src="js/render/assetPacks.js"></script>
<script src="js/render/avatarVisuals.js"></script>
<script src="js/render/designedRoom.js"></script>
<script src="js/render/asciiRenderer.js"></script>
<script src="js/render/tileRenderer.js"></script>
+9 -1
View File
@@ -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');
});
});