From 3195386ba1a94865a950232ffb4a201dbe7cc96a Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 03:41:11 -0400 Subject: [PATCH] refactor: resolve avatar visuals through render kit --- html/index.html | 1 + html/js/core/singlePlayerAvatar.js | 139 +++++++------------- html/js/display/screenManager.js | 25 +++- html/js/render/avatarVisuals.js | 197 +++++++++++++++++++++++++++++ html/tavern.html | 1 + test/singlePlayerAvatar.test.js | 10 +- 6 files changed, 271 insertions(+), 102 deletions(-) create mode 100644 html/js/render/avatarVisuals.js 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'); + }); });