diff --git a/html/index.html b/html/index.html index 8b2a8a6..a7b7ab3 100644 --- a/html/index.html +++ b/html/index.html @@ -79,6 +79,7 @@ + diff --git a/html/js/render/assetPacks.js b/html/js/render/assetPacks.js index cdc7085..1b75a4b 100644 --- a/html/js/render/assetPacks.js +++ b/html/js/render/assetPacks.js @@ -165,7 +165,14 @@ }; RK.activeThemeId = 'roguelike-interior'; - RK.theme = function (id) { return RK.THEMES[id || RK.activeThemeId] || null; }; + RK.theme = function (id) { + // With no explicit id, use the active topdown pack (registry-selected + entitlement-gated). + if (!id && RK.activePackId) { + var ap = RK.activePackId('topdown'); + if (ap && RK.THEMES[ap]) return RK.THEMES[ap]; + } + return RK.THEMES[id || RK.activeThemeId] || null; + }; // Effective tile map = theme defaults merged with localStorage overrides from the picker. RK.tileMap = function (id) { @@ -305,6 +312,19 @@ } }; + // Register the built-in RENDER packs in the pack registry (packRegistry.js) so iso/3D become + // multi-pack like topdown themes already are. Each maps to its catalog id; the active pack per + // projection is entitlement-gated + user-selectable. Adding a pack later = registerPack(...) + + // assets + a catalog row. Backward-compatible: one pack per projection resolves to these. + if (RK.registerPack) { + RK.registerPack({ id: 'roguelike-interior', label: 'Roguelike Interior', projection: 'topdown', kind: 'tiles', assets: RK.THEMES['roguelike-interior'] }); + RK.registerPack({ id: 'iso-dungeon', label: 'Isometric Dungeon', projection: 'iso', kind: 'tiles', assets: RK.isoAssets }); + RK.registerPack({ id: 'kenney-3d-characters', label: 'Animated 3D', projection: '3d', kind: 'skin', assets: RK.threeAssets }); + } + // Active-pack resolvers the renderers read (fall back to the single default if the registry is absent). + RK.activeIsoAssets = function () { return (RK.activePackAssets && RK.activePackAssets('iso')) || RK.isoAssets; }; + RK.activeThreeAssets = function () { return (RK.activePackAssets && RK.activePackAssets('3d')) || RK.threeAssets; }; + RK.packForProjection = function (projection) { for (var id in RK.PACKS) { if (RK.PACKS[id].projection === projection) return RK.PACKS[id]; diff --git a/html/js/render/avatarVisuals.js b/html/js/render/avatarVisuals.js index 62ec551..3607dce 100644 --- a/html/js/render/avatarVisuals.js +++ b/html/js/render/avatarVisuals.js @@ -73,13 +73,13 @@ } function isoCharacterFor(appearance) { - var assets = RK.isoAssets || {}; + var assets = (RK.activeIsoAssets && RK.activeIsoAssets()) || RK.isoAssets || {}; var chars = assets.characters || {}; return chars[appearance.avatar] || chars.fallback || assets.character || null; } function modelFor(appearance) { - var assets = RK.threeAssets || {}; + var assets = (RK.activeThreeAssets && RK.activeThreeAssets()) || RK.threeAssets || {}; var models = assets.models || {}; return models[appearance.avatar] || models.fallback || null; } diff --git a/html/js/render/isoRenderer.js b/html/js/render/isoRenderer.js index 398c502..12823fc 100644 --- a/html/js/render/isoRenderer.js +++ b/html/js/render/isoRenderer.js @@ -7,7 +7,7 @@ opts = opts || {}; this.name = 'iso'; this.host = host; - this.assets = (root.RK && RK.isoAssets) || {}; + this.assets = (root.RK && ((RK.activeIsoAssets && RK.activeIsoAssets()) || RK.isoAssets)) || {}; this.tileW = opts.tileW || (this.assets.tile && this.assets.tile.w) || 84; this.tileH = opts.tileH || (this.assets.tile && this.assets.tile.h) || 42; this.imageW = opts.imageW || (this.assets.tile && this.assets.tile.imageW) || 92; diff --git a/html/js/render/packRegistry.js b/html/js/render/packRegistry.js new file mode 100644 index 0000000..86d55c3 --- /dev/null +++ b/html/js/render/packRegistry.js @@ -0,0 +1,75 @@ +// Pack registry — the engine behind "add more 2D tilesets / iso packs / 3D skins as products". +// +// A PACK is a rich, self-contained render environment for a projection (topdown | iso | 3d). Because +// every scene (tavern OR dungeon) is kind-based (floor/wall/table/torch/monster…), the SAME pack +// renders both — packs are interchangeable across town and dungeon. Each pack maps 1:1 to a catalog +// entry (id/tier/threshold), so it's a monetizable product gated by entitlements. +// +// This module makes packs MULTI (like RK.THEMES already is for topdown) and adds active-pack +// selection per projection. Renderers ask for the active pack's assets; a picker lets an unlocked +// user switch. Adding a pack then = register(def) + drop assets + a catalog row. +(function (root) { + 'use strict'; + var RK = root.RK = root.RK || {}; + + // id -> { id, label, projection: 'topdown'|'iso'|'3d', kind, assets } + // `assets` is the render-kit payload for that projection (theme tiles / iso set / 3d models). + RK.PACK_REGISTRY = RK.PACK_REGISTRY || {}; + + // Storage: localStorage in the browser, an in-memory map under Node/tests. + var mem = {}; + function store() { + try { if (root.localStorage) return root.localStorage; } catch (_) {} + return { getItem: function (k) { return k in mem ? mem[k] : null; }, setItem: function (k, v) { mem[k] = String(v); } }; + } + function key(projection) { return 'rk_pack_' + projection; } + + RK.registerPack = function (def) { + if (!def || !def.id || !def.projection) return null; + RK.PACK_REGISTRY[def.id] = def; + return def; + }; + RK.getPackDef = function (id) { return (id && RK.PACK_REGISTRY[id]) || null; }; + + RK.packsForProjection = function (projection) { + var out = []; + for (var id in RK.PACK_REGISTRY) { + if (RK.PACK_REGISTRY[id].projection === projection) out.push(RK.PACK_REGISTRY[id]); + } + return out; + }; + + // Packs the user can actually use for a projection (free ones + entitlement-unlocked ones). + RK.unlockedPacks = function (projection) { + return RK.packsForProjection(projection).filter(function (p) { + return !RK.canUsePack || RK.canUsePack(p.id); + }); + }; + + // The user's chosen pack if it's registered for this projection AND unlocked; otherwise the + // first unlocked pack; otherwise null. + RK.activePackId = function (projection) { + var saved = store().getItem(key(projection)); + var def = saved && RK.PACK_REGISTRY[saved]; + if (def && def.projection === projection && (!RK.canUsePack || RK.canUsePack(saved))) return saved; + var unlocked = RK.unlockedPacks(projection); + return unlocked.length ? unlocked[0].id : null; + }; + + RK.activePack = function (projection) { return RK.getPackDef(RK.activePackId(projection)); }; + RK.activePackAssets = function (projection) { + var def = RK.activePack(projection); + return def ? def.assets : null; + }; + + // Select a pack (persisted per projection). Refused if unregistered or locked. Returns success. + RK.setActivePack = function (id) { + var def = RK.getPackDef(id); + if (!def) return false; + if (RK.canUsePack && !RK.canUsePack(id)) return false; + store().setItem(key(def.projection), id); + return true; + }; + + if (typeof module !== 'undefined' && module.exports) module.exports = RK; +})(typeof window !== 'undefined' ? window : this); diff --git a/html/tavern.html b/html/tavern.html index 95bc17c..80acccb 100644 --- a/html/tavern.html +++ b/html/tavern.html @@ -15,6 +15,7 @@ + diff --git a/test/packRegistry_ultra.test.js b/test/packRegistry_ultra.test.js new file mode 100644 index 0000000..e86be16 --- /dev/null +++ b/test/packRegistry_ultra.test.js @@ -0,0 +1,50 @@ +/** + * Pack registry — the engine for multi-pack, interchangeable render environments (products). + */ +describe('pack registry', () => { + let RK; + beforeEach(() => { jest.resetModules(); RK = require('../html/js/render/packRegistry'); }); + + function seed() { + RK.registerPack({ id: 'topA', label: 'A', projection: 'topdown', assets: { a: 1 } }); + RK.registerPack({ id: 'isoFree', label: 'IsoFree', projection: 'iso', assets: { i: 1 } }); + RK.registerPack({ id: 'isoPrem', label: 'IsoPrem', projection: 'iso', assets: { i: 2 } }); + } + + test('registers packs and lists them by projection', () => { + seed(); + expect(RK.packsForProjection('iso').map(p => p.id).sort()).toEqual(['isoFree', 'isoPrem']); + expect(RK.getPackDef('topA').assets).toEqual({ a: 1 }); + expect(RK.registerPack({ id: 'x' })).toBeNull(); // no projection -> rejected + }); + + test('unlockedPacks filters by canUsePack', () => { + seed(); + RK.canUsePack = (id) => id !== 'isoPrem'; + expect(RK.unlockedPacks('iso').map(p => p.id)).toEqual(['isoFree']); + }); + + test('active pack = saved-if-unlocked, else first unlocked; assets resolve', () => { + seed(); + RK.canUsePack = () => true; + expect(RK.activePackId('iso')).toBe('isoFree'); // default = first unlocked + expect(RK.setActivePack('isoPrem')).toBe(true); + expect(RK.activePackId('iso')).toBe('isoPrem'); // saved choice sticks + expect(RK.activePackAssets('iso')).toEqual({ i: 2 }); + }); + + test('a saved pack that later locks falls back; setActivePack refuses a locked pack', () => { + seed(); + RK.canUsePack = () => true; + RK.setActivePack('isoPrem'); + RK.canUsePack = (id) => id !== 'isoPrem'; // access lost + expect(RK.activePackId('iso')).toBe('isoFree'); // graceful fallback + expect(RK.setActivePack('isoPrem')).toBe(false); // refused + }); + + test('a projection with no packs resolves to null', () => { + seed(); + expect(RK.activePackId('3d')).toBeNull(); + expect(RK.activePackAssets('3d')).toBeNull(); + }); +});