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