refactor: route render packs through avatar visuals

This commit is contained in:
jwinterm
2026-07-06 03:53:13 -04:00
parent 3195386ba1
commit cb9efbe66c
7 changed files with 279 additions and 18 deletions
+15
View File
@@ -235,8 +235,23 @@
}
};
RK.packForProjection = function (projection) {
for (var id in RK.PACKS) {
if (RK.PACKS[id].projection === projection) return RK.PACKS[id];
}
return null;
};
RK.resolveAppearance = function (entity, projection) {
entity = entity || {};
var baseAppearance = entity.appearance || { avatar: entity.avatar || 'default', tint: 'none', equipment: FREE_EQUIPMENT };
if (RK.avatarVisuals && RK.avatarVisuals.resolve) {
return RK.avatarVisuals.resolve(baseAppearance, {
projection: projection || 'topdown',
context: 'room-entity',
entity: entity
});
}
var appearance = entity.appearance || { avatar: entity.avatar || 'default', tint: 'none', equipment: FREE_EQUIPMENT };
var avatar = appearance.avatar || entity.avatar || 'default';
if (projection === 'iso') {
+90 -6
View File
@@ -14,8 +14,19 @@
return DEFAULT_APPEARANCE;
}
function kindFor(appearance) {
function topdownPackFor(appearance, kind) {
if (kind === 'skin') {
var skin = RK.SKINS && RK.SKINS[appearance.avatar];
return (skin && skin.pack) || 'generated-skins';
}
if (kind === 'model3d') return 'kenney-3d-characters';
return 'roguelike-interior';
}
function kindFor(appearance, projection) {
if (!appearance) return 'legacy';
if (projection === 'iso') return 'iso';
if (projection === '3d') return 'model3d';
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';
@@ -37,19 +48,57 @@
return 'Character';
}
function packFor(appearance, kind, projection) {
if (projection === 'iso') return 'iso-dungeon';
if (projection === '3d') return 'kenney-3d-characters';
return topdownPackFor(appearance, kind);
}
function canUsePack(id) {
return !id || !RK.canUsePack || RK.canUsePack(id);
}
function isoCharacterFor(appearance) {
var assets = RK.isoAssets || {};
var chars = assets.characters || {};
return chars[appearance.avatar] || chars.fallback || assets.character || null;
}
function modelFor(appearance) {
var assets = RK.threeAssets || {};
var models = assets.models || {};
return models[appearance.avatar] || models.fallback || null;
}
function resolve(appearance, opts) {
opts = opts || {};
var ap = normalize(appearance);
var kind = kindFor(ap);
return {
var projection = opts.projection || 'legacy-rot';
var kind = kindFor(ap, projection);
var pack = packFor(ap, kind, projection);
var visual = {
appearance: ap,
kind: kind,
projection: opts.projection || 'legacy-rot',
projection: projection,
context: opts.context || 'player',
label: labelFor(ap),
pack: pack,
allowed: canUsePack(pack),
fallbackTile: fallbackTile(opts),
canvas: kind === 'char' || kind === 'skin'
canvas: kind === 'char' || kind === 'skin',
entity: opts.entity || null
};
if (projection === 'iso') {
visual.assets = RK.isoAssets || null;
visual.character = isoCharacterFor(ap);
visual.canvas = false;
} else if (projection === '3d') {
visual.assets = RK.threeAssets || null;
visual.model = modelFor(ap);
visual.canvas = false;
visual.three = true;
}
return visual;
}
function rememberLoad(key, start, onReady) {
@@ -67,6 +116,7 @@
function ensureCanvasReady(visual, onReady) {
visual = visual && visual.appearance ? visual : resolve(visual);
if (!visual.allowed) return false;
if (visual.kind === 'char') {
if (RK.charAtlas && RK.charAtlas()) return true;
if (!RK.loadCharAtlas) return false;
@@ -134,6 +184,13 @@
var ap = RK.charAppearance({ avatar: visual.appearance.avatar, appearance: visual.appearance });
ctx.imageSmoothingEnabled = false;
if (entity.you) {
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(cx, viewport.screenY * cell + cell - 2, dw * 0.3, cell * 0.16, 0, 0, Math.PI * 2);
ctx.stroke();
}
function drawComposite(dx, dy2) {
RK.drawCharTileCanvas(ctx, ch.frame, ap.tint, dx, dy2, dw, dh, ap.colors, 'base');
RK.charOverlayParts(ap).forEach(function (part) {
@@ -149,6 +206,12 @@
} else {
drawComposite(cx - dw / 2, dy);
}
if (entity.label) {
ctx.fillStyle = '#d7dbe0';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(entity.label, cx, dy - 2);
}
return true;
}
@@ -171,14 +234,35 @@
var dx = cx - dw / 2;
var dy = (viewport.screenY * cell + cell) - dh;
ctx.imageSmoothingEnabled = true;
var midY = dy + dh * 0.45;
var halo = ctx.createRadialGradient(cx, midY, 0, cx, midY, dw * 0.8);
halo.addColorStop(0, 'rgba(255,226,180,0.30)');
halo.addColorStop(1, 'rgba(255,226,180,0)');
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(cx, midY, dw * 0.8, 0, Math.PI * 2);
ctx.fill();
if (entity.you) {
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(cx, viewport.screenY * cell + cell - 2, dw * 0.3, cell * 0.16, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.drawImage(rec.img, frame.col * skin.frameW, frame.row * skin.frameH, skin.frameW, skin.frameH, dx, dy, dw, dh);
if (entity.label) {
ctx.fillStyle = '#d7dbe0';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(entity.label, cx, dy - 2);
}
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 (!ctx || !entity || !viewport || !visual.allowed || !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;
+18 -7
View File
@@ -18,10 +18,11 @@
this.cache = {};
this.last = {};
this.lastScene = null;
this.enabled = !(root.RK && RK.canUsePack && this.assets.pack && !RK.canUsePack(this.assets.pack));
this._raf = null;
this._animating = false;
host.appendChild(this.canvas);
this._preload();
if (this.enabled) this._preload();
}
IsoRenderer.prototype._load = function (url) {
@@ -68,16 +69,26 @@
return tiles[kind] || tiles.fallback || tiles.floor;
};
IsoRenderer.prototype._visualFor = function (e) {
var appearance = (e && e.appearance) || { avatar: (e && e.avatar) || 'default' };
if (root.RK && RK.avatarVisuals && RK.avatarVisuals.resolve) {
return RK.avatarVisuals.resolve(appearance, { projection: 'iso', context: 'tavern', entity: e });
}
if (root.RK && RK.resolveAppearance) return RK.resolveAppearance(e, 'iso');
return null;
};
IsoRenderer.prototype._charFrame = function (e, now) {
var visual = this._visualFor(e);
if (visual && visual.allowed === false) return null;
var key = e.id || 'anon';
var st = this.last[key] || (this.last[key] = { x: e.x, y: e.y, t: 0 });
if (st.x !== e.x || st.y !== e.y) { st.x = e.x; st.y = e.y; st.t = now; }
var moving = (now - st.t) < 360;
var resolved = (root.RK && RK.resolveAppearance) ? RK.resolveAppearance(e, 'iso') : null;
var ch = (resolved && resolved.character) || (this.assets.character || {});
if (!moving || !ch.run || ch.run.length === 0) return ch.idle;
var ch = (visual && visual.character) || (this.assets.character || {});
if (!moving || !ch.run || ch.run.length === 0) return { url: ch.idle, character: ch };
this._animating = true;
return ch.run[Math.floor(now / 80) % ch.run.length];
return { url: ch.run[Math.floor(now / 80) % ch.run.length], character: ch };
};
IsoRenderer.prototype.render = function (scene) {
@@ -126,9 +137,9 @@
if (rec && rec.ready) this._drawImage(rec.img, it.sx, it.sy + this.tileH, this.imageW, this.imageH);
} else if (it.type === 'entity') {
var frame = this._charFrame(it.e, now);
rec = this._load(frame);
rec = this._load(frame && frame.url);
if (rec && rec.ready) {
var ch = this.assets.character || {};
var ch = frame.character || this.assets.character || {};
this._drawImage(rec.img, it.sx, it.sy + this.tileH * 0.95, ch.imageW || 74, ch.imageH || 148);
} else {
ctx.beginPath();
+5
View File
@@ -32,6 +32,7 @@
};
RK.createRenderer = function (mode, host, opts) {
if (!RK.canUseMode(mode)) return new RK.TileRenderer(host, opts);
if (mode === 'ascii' && RK.AsciiRenderer) return new RK.AsciiRenderer(host, opts);
if (mode === 'iso' && RK.IsoRenderer) return new RK.IsoRenderer(host, opts);
if (mode === '3d' && RK.ThreeRenderer && RK.THREE) return new RK.ThreeRenderer(host, opts);
@@ -92,6 +93,10 @@
// Create a renderer, loading Pixi on demand for Fancy; falls back to Tiled if Pixi fails.
RK.createRendererAsync = function (mode, host, opts, cb) {
if (!RK.canUseMode(mode)) {
cb(new RK.TileRenderer(host, opts));
return;
}
if (mode === 'fancy') {
RK.ensurePixi(function (ok) {
if (!ok) { cb(new RK.TileRenderer(host, opts)); return; }
+12 -3
View File
@@ -117,14 +117,23 @@
});
};
ThreeRenderer.prototype._visualFor = function (e) {
var appearance = (e && e.appearance) || { avatar: (e && e.avatar) || 'default' };
if (root.RK && RK.avatarVisuals && RK.avatarVisuals.resolve) {
return RK.avatarVisuals.resolve(appearance, { projection: '3d', context: 'tavern', entity: e });
}
if (root.RK && RK.resolveAppearance) return RK.resolveAppearance(e, '3d');
return null;
};
ThreeRenderer.prototype._makeEntity = function (e) {
var T = this.THREE, self = this;
var shell = new T.Group();
shell._body = this._fallbackAvatar(e);
shell.add(shell._body);
var resolved = (root.RK && RK.resolveAppearance) ? RK.resolveAppearance(e, '3d') : null;
if (root.RK && RK.canUsePack && resolved && RK.canUsePack(resolved.pack)) {
this._loadModel(resolved, function (rec) {
var visual = this._visualFor(e);
if (visual && visual.allowed !== false && visual.model) {
this._loadModel(visual, function (rec) {
if (!rec || !rec.gltf || !shell.parent) return;
shell.remove(shell._body);
var model = rec.gltf.scene.clone(true);
+15 -2
View File
@@ -62,8 +62,21 @@
for (var i = 0; i < scene.entities.length; i++) {
var e = scene.entities[i];
// Roguelike character sprite, or premium animated skin; else the fallback circle.
if (window.RK && RK.isChar && RK.isChar(e.avatar) && RK.drawCharCanvas(ctx, e, cell, now)) continue;
if (window.RK && RK.isSkin && RK.isSkin(e.avatar) && RK.drawSkinCanvas(ctx, e, cell, now)) continue;
if (window.RK && RK.avatarVisuals && RK.avatarVisuals.drawTopdownWorld) {
var visual = RK.avatarVisuals.resolve(e.appearance || { avatar: e.avatar }, {
projection: 'topdown',
context: 'tavern',
entity: e
});
if (RK.avatarVisuals.drawTopdownWorld(ctx, visual, e, {
screenX: e.x,
screenY: e.y,
cell: cell
}, { now: now })) continue;
} else {
if (window.RK && RK.isChar && RK.isChar(e.avatar) && RK.drawCharCanvas(ctx, e, cell, now)) continue;
if (window.RK && RK.isSkin && RK.isSkin(e.avatar) && RK.drawSkinCanvas(ctx, e, cell, now)) continue;
}
var cx = e.x * cell + cell / 2, cy = e.y * cell + cell / 2, r = cell * 0.36;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
+124
View File
@@ -0,0 +1,124 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function loadRenderKit() {
const context = {
console,
window: {
console,
localStorage: {
getItem: () => null,
setItem: () => {}
}
}
};
context.window.window = context.window;
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/render/assetPacks.js'), 'utf8'),
context
);
const RK = context.window.RK;
RK.normalizeAppearance = input => {
if (input && input.appearance && !input.avatar) input = input.appearance;
if (typeof input === 'string') input = { avatar: input };
input = input || {};
return {
avatar: input.avatar || 'default',
tint: input.tint || 'none',
equipment: input.equipment || { body: 'none', head: 'none', shield: 'none', weapon: 'none' },
colors: input.colors
};
};
RK.appearance = input => ({ label: (input && input.avatar) || 'Character' });
RK.SKINS = {
'monero-knight': { id: 'monero-knight', label: 'Monero Knight', pack: 'generated-skins' }
};
RK.isSkin = id => !!RK.SKINS[id];
RK.CHARS = { 'char-ranger': { id: 'char-ranger', label: 'Ranger', frame: 432 } };
RK.isChar = id => !!RK.CHARS[id];
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/render/avatarVisuals.js'), 'utf8'),
context
);
RK.TileRenderer = function TileRenderer() { this.name = 'tiles'; };
RK.IsoRenderer = function IsoRenderer() { this.name = 'iso'; };
RK.ThreeRenderer = function ThreeRenderer() { this.name = '3d'; };
RK.THREE = { THREE: {}, GLTFLoader: function GLTFLoader() {} };
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/render/renderModes.js'), 'utf8'),
context
);
return RK;
}
describe('render pack visual resolver', () => {
test('resolves iso and 3D visuals through pack entitlements', () => {
const RK = loadRenderKit();
RK.setEntitlementSnapshot({ totalCreditsPurchased: 0, premium: false, packs: {} });
const locked3d = RK.avatarVisuals.resolve(
{ avatar: 'kenney-survivor-male' },
{ projection: '3d' }
);
expect(locked3d).toMatchObject({
projection: '3d',
kind: 'model3d',
pack: 'kenney-3d-characters',
allowed: false
});
expect(locked3d.model.url).toContain('survivorMaleB.glb');
RK.setEntitlementSnapshot({ totalCreditsPurchased: 1 });
const iso = RK.avatarVisuals.resolve({ avatar: 'char-ranger' }, { projection: 'iso' });
expect(iso).toMatchObject({ projection: 'iso', kind: 'iso', pack: 'iso-dungeon', allowed: true });
expect(iso.character.idle).toContain('Male_0_Idle0.png');
const compat = RK.resolveAppearance(
{ appearance: { avatar: 'kenney-survivor-female' } },
'3d'
);
expect(compat.model.url).toContain('survivorFemaleA.glb');
});
test('renderer factory falls back when premium modes are locked', () => {
const RK = loadRenderKit();
RK.setEntitlementSnapshot({ totalCreditsPurchased: 0, premium: false, packs: {} });
expect(RK.canUseMode('iso')).toBe(false);
expect(RK.createRenderer('iso', {}, {}).name).toBe('tiles');
RK.setEntitlementSnapshot({ totalCreditsPurchased: 1 });
expect(RK.canUseMode('iso')).toBe(true);
expect(RK.createRenderer('iso', {}, {}).name).toBe('iso');
});
test('render pack code stays out of payment and account APIs', () => {
const files = [
'../html/js/render/assetPacks.js',
'../html/js/render/avatarVisuals.js',
'../html/js/render/renderModes.js',
'../html/js/render/isoRenderer.js',
'../html/js/render/threeRenderer.js',
'../html/js/render/tileRenderer.js'
];
const source = files.map(file => fs.readFileSync(path.join(__dirname, file), 'utf8')).join('\n');
[
'PaymentUI',
'request_payment',
'payment_',
'wallet',
'subaddress',
'/api/',
'user_id',
'socket.emit'
].forEach(forbidden => {
expect(source).not.toContain(forbidden);
});
});
});