diff --git a/html/js/render/assetPacks.js b/html/js/render/assetPacks.js
index f28c714..e8927d4 100644
--- a/html/js/render/assetPacks.js
+++ b/html/js/render/assetPacks.js
@@ -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') {
diff --git a/html/js/render/avatarVisuals.js b/html/js/render/avatarVisuals.js
index de2f8c2..2f958b1 100644
--- a/html/js/render/avatarVisuals.js
+++ b/html/js/render/avatarVisuals.js
@@ -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;
diff --git a/html/js/render/isoRenderer.js b/html/js/render/isoRenderer.js
index 8f4c55b..7d6d596 100644
--- a/html/js/render/isoRenderer.js
+++ b/html/js/render/isoRenderer.js
@@ -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();
diff --git a/html/js/render/renderModes.js b/html/js/render/renderModes.js
index 929bbde..a1f5470 100644
--- a/html/js/render/renderModes.js
+++ b/html/js/render/renderModes.js
@@ -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; }
diff --git a/html/js/render/threeRenderer.js b/html/js/render/threeRenderer.js
index 514670d..a9e3495 100644
--- a/html/js/render/threeRenderer.js
+++ b/html/js/render/threeRenderer.js
@@ -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);
diff --git a/html/js/render/tileRenderer.js b/html/js/render/tileRenderer.js
index cec61c1..ded1c39 100644
--- a/html/js/render/tileRenderer.js
+++ b/html/js/render/tileRenderer.js
@@ -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);
diff --git a/test/renderPackResolver.test.js b/test/renderPackResolver.test.js
new file mode 100644
index 0000000..1645354
--- /dev/null
+++ b/test/renderPackResolver.test.js
@@ -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);
+ });
+ });
+});