fix(tavern): repaint permitted avatars and prioritize the live arena

This commit is contained in:
2026-10-21 13:55:00 -04:00
parent c387d64067
commit 87e688b4ac
8 changed files with 385 additions and 53 deletions
+6 -2
View File
@@ -195,13 +195,14 @@ describe('renderer controls and failed skin fallback', () => {
test('a normal character still waits for its atlas and resolves the render callback', () => {
let atlasReady;
let atlasLoaded = false;
const loadAtlas = jest.fn(callback => { atlasReady = callback; });
const RK = {
CHARS: { 'char-villager': { frame: 1 } },
normalizeAppearance: appearance => ({ avatar: typeof appearance === 'string' ? appearance : appearance.avatar || 'default' }),
appearance: appearance => ({ label: appearance.avatar }),
isChar: id => id === 'char-villager',
charAtlas: () => atlasLoaded ? {} : null,
loadCharAtlas: callback => { atlasReady = callback; },
loadCharAtlas: loadAtlas,
charAppearance: appearance => ({ avatar: appearance.avatar, tint: 'none', colors: {}, equipment: {} }),
charFrame: () => ({ squash: 1, bob: 0 }),
drawCharTileCanvas: jest.fn(),
@@ -218,7 +219,10 @@ describe('renderer controls and failed skin fallback', () => {
const ready = jest.fn();
const canvas = { beginPath() {}, ellipse() {}, stroke() {} };
const entity = { id: 'me', x: 1, y: 1, facing: 'down', you: true };
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, { onReady: ready })).toBe(false);
for (let i = 0; i < 12; i++) {
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, { onReady: ready })).toBe(false);
}
expect(loadAtlas).toHaveBeenCalledTimes(1);
atlasLoaded = true;
atlasReady();
+151
View File
@@ -0,0 +1,151 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function loadTileRenderer(options) {
options = options || {};
const frames = [];
const callbacks = [];
let atlasReady = false;
let skinReady = false;
let skinFailed = false;
const sprites = [];
let renderPasses = 0;
const ctx = {
fillRect() {}, strokeRect() {}, save() {}, restore() {}, beginPath() {}, ellipse() {},
arc() {}, fill() {}, stroke() {}, clearRect() {}, drawImage() {}, fillText() {}
};
const canvas = { className: '', width: 0, height: 0, parentNode: null, getContext: () => ctx };
const window = {
requestAnimationFrame(callback) { frames.push(callback); return frames.length; },
cancelAnimationFrame() {},
RK: {
themeAtlas: () => null,
tileMap: () => ({}),
roomReady: () => false,
charAtlas: () => atlasReady ? {} : null,
skinSheet: () => skinReady ? {} : null,
skinSheetFailed: () => skinFailed,
avatarVisuals: {
resolve: appearance => ({
kind: appearance.avatar === 'generated-test-skin' ? 'skin' : 'char',
allowed: true,
appearance
}),
drawTopdownWorld(_ctx, visual, entity, _viewport, opts) {
if (visual.kind === 'skin' && skinReady) { sprites.push(entity.id); return true; }
if (visual.kind === 'skin' && skinFailed && atlasReady) { sprites.push(entity.id); return true; }
if (visual.kind === 'char' && atlasReady) { sprites.push(entity.id); return true; }
if (visual.kind === 'skin' && options.transitionToChar) {
callbacks.push(opts.onReady);
return false;
}
if (options.completeWithoutReady) { opts.onReady(); return false; }
if (options.completeWithReady) { atlasReady = true; opts.onReady(); return false; }
callbacks.push(opts.onReady);
return false;
}
}
}
};
const document = { createElement: () => canvas };
const context = { window, document, RK: window.RK, Date };
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/tileRenderer.js'), 'utf8'), context);
const host = { appendChild(child) { child.parentNode = this; }, removeChild(child) { child.parentNode = null; } };
const renderer = new window.RK.TileRenderer(host);
renderer._collectEmitters = () => {};
renderer._snapshot = () => { renderPasses += 1; };
renderer._ensureEmbers = () => {};
renderer._composite = () => {};
renderer._startLoop = () => {};
function scene(id) {
return {
cols: 1, rows: 1, grid: [['floor']], legend: { floor: { color: '#654321' } },
entities: [{ id, kind: 'avatar', x: 0, y: 0, appearance: { avatar: 'default' } }]
};
}
return {
callbacks, frames, renderer, scene, sprites,
ready() { atlasReady = true; },
failSkin() { skinFailed = true; },
get renderPasses() { return renderPasses; }
};
}
describe('TileRenderer character atlas repaint', () => {
test('repaints the latest retained scene once when the free character atlas becomes ready', () => {
const kit = loadTileRenderer();
const first = kit.scene('first');
first.entities.push({ id: 'first-other', kind: 'avatar', x: 0, y: 0, appearance: { avatar: 'default' } });
const latest = kit.scene('latest');
latest.entities.push({ id: 'latest-other', kind: 'avatar', x: 0, y: 0, appearance: { avatar: 'default' } });
kit.renderer.render(first);
kit.renderer.render(latest);
expect(new Set(kit.callbacks).size).toBe(1);
expect(kit.sprites).toEqual([]);
kit.ready();
kit.callbacks.forEach(callback => callback());
expect(kit.frames).toHaveLength(1);
kit.frames.shift()();
expect(kit.sprites).toEqual(['latest', 'latest-other']);
expect(kit.renderPasses).toBe(3);
expect(kit.frames).toHaveLength(0);
});
test('does not repaint after destruction, including when readiness was already queued', () => {
const kit = loadTileRenderer();
kit.renderer.render(kit.scene('removed'));
kit.ready();
kit.callbacks[0]();
expect(kit.frames).toHaveLength(1);
kit.renderer.destroy();
kit.frames.shift()();
expect(kit.renderPasses).toBe(1);
expect(kit.sprites).toEqual([]);
});
test('does not retry a loader callback that completes without a drawable atlas', () => {
const kit = loadTileRenderer({ completeWithoutReady: true });
kit.renderer.render(kit.scene('unavailable'));
expect(kit.frames).toHaveLength(0);
expect(kit.renderPasses).toBe(1);
});
test('retains the current scene before a loader completion callback can repaint it', () => {
const kit = loadTileRenderer({ completeWithReady: true });
kit.renderer.render(kit.scene('synchronous-ready'));
expect(kit.frames).toHaveLength(1);
kit.frames.shift()();
expect(kit.sprites).toEqual(['synchronous-ready']);
expect(kit.renderPasses).toBe(2);
expect(kit.frames).toHaveLength(0);
});
test('repaints after a failed skin so the free character fallback can load and draw', () => {
const kit = loadTileRenderer({ transitionToChar: true });
const skinScene = kit.scene('free-fallback');
skinScene.entities[0].appearance = { avatar: 'generated-test-skin' };
kit.renderer.render(skinScene);
expect(kit.callbacks).toHaveLength(1);
expect(kit.frames).toHaveLength(0);
kit.failSkin();
kit.callbacks[0]();
expect(kit.frames).toHaveLength(1);
kit.frames.shift()();
expect(kit.callbacks).toHaveLength(2);
expect(kit.sprites).toEqual([]);
kit.ready();
kit.callbacks[1]();
expect(kit.frames).toHaveLength(1);
kit.frames.shift()();
expect(kit.sprites).toEqual(['free-fallback']);
expect(kit.renderPasses).toBe(3);
expect(kit.frames).toHaveLength(0);
});
});