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); + }); + }); +});