feat(render): pack registry — multi-pack, interchangeable render environments

The engine behind 'add more 2D tilesets / iso packs / 3D skins as products'. A
pack is a self-contained render environment for a projection (topdown/iso/3d);
because scenes are kind-based, the SAME pack renders both town and dungeon
(interchangeable), and each maps to a catalog id (monetizable, entitlement-gated).

- packRegistry.js: registerPack / packsForProjection / unlockedPacks (canUsePack)
  / activePackId (saved-if-unlocked else first unlocked) / setActivePack /
  activePackAssets. Per-projection active-pack selection, persisted. In-memory
  store fallback so it's testable under Node.
- assetPacks: registers the 3 built-ins (roguelike-interior topdown, iso-dungeon,
  kenney-3d-characters) + activeIsoAssets()/activeThreeAssets() resolvers; RK.theme()
  now defaults to the active topdown pack.
- isoRenderer + avatarVisuals (iso/3d character lookup) read the ACTIVE pack.
- packRegistry loaded before assetPacks in tavern + game.

Backward-compatible: one pack per projection resolves to today's defaults — nothing
changes visually. Adding a pack now = registerPack(...) + assets + a catalog row.
Tests: +5 (register/list, unlock filter, active/saved/fallback, refuse locked).
Suite 58 suites / 419 tests green.
This commit is contained in:
jwinterm
2026-07-11 13:40:29 -04:00
parent 6d0334f4f2
commit 11b7fb1272
7 changed files with 151 additions and 4 deletions
+1
View File
@@ -79,6 +79,7 @@
<script src="js/render/atlas.js"></script>
<script src="js/render/skins.js"></script>
<script src="js/render/charSprites.js"></script>
<script src="js/render/packRegistry.js"></script>
<script src="js/render/assetPacks.js"></script>
<script src="js/render/renderModes.js"></script>
<script src="js/render/charCustomize.js"></script>
+21 -1
View File
@@ -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];
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+75
View File
@@ -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);
+1
View File
@@ -15,6 +15,7 @@
<script src="js/render/charSprites.js"></script>
<script src="js/render/catSprites.js"></script>
<script src="js/render/zoomControl.js"></script>
<script src="js/render/packRegistry.js"></script>
<script src="js/render/assetPacks.js"></script>
<script src="js/render/avatarVisuals.js"></script>
<script src="js/render/designedRoom.js"></script>
+50
View File
@@ -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();
});
});