diff --git a/html/js/render/assetPacks.js b/html/js/render/assetPacks.js
index 6c27c62..45f2d82 100644
--- a/html/js/render/assetPacks.js
+++ b/html/js/render/assetPacks.js
@@ -161,6 +161,31 @@
treasure: [42, 15], // 897 — item
torch: [50, 10] // 620 — rubble / prop
}
+ },
+ // Second topdown pack — same sheet, a DUNGEON palette (stone floors, barrels/crates instead
+ // of the wood-interior furniture). A distinct look with zero new assets, and it's the
+ // "interchangeable dungeon" style (the tiled renderer's lighting/shadows are the built-in FX).
+ 'roguelike-dungeon': {
+ id: 'roguelike-dungeon',
+ label: 'Roguelike Dungeon',
+ tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
+ fx: true,
+ tiles: {
+ floor: [6, 2], // 120 — stone floor
+ floor2: [13, 17], // 982 — plank accent
+ wall: [13, 12], // 697 — masonry
+ window: [42, 2], // 156 — window
+ bar: [23, 0], // barrels (a dungeon has no bar)
+ keg: [23, 0],
+ shelf: [25, 0], // crate
+ table: [25, 0], // crate as a table
+ chair: [23, 0], // barrel as a stool
+ barrel: [23, 0],
+ crate: [25, 0],
+ rug: [6, 2], // stone (no rug)
+ door: [37, 9],
+ entrance: [37, 9], exit: [37, 9], treasure: [42, 15], torch: [50, 10]
+ }
}
};
RK.activeThemeId = 'roguelike-interior';
@@ -342,6 +367,7 @@
// 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: 'roguelike-dungeon', label: 'Roguelike Dungeon', projection: 'topdown', kind: 'tiles', assets: RK.THEMES['roguelike-dungeon'] });
RK.registerPack({ id: 'iso-dungeon', label: 'Isometric Dungeon', projection: 'iso', kind: 'tiles', assets: RK.isoAssets });
RK.registerPack({ id: 'iso-medieval', label: 'Medieval Town', projection: 'iso', kind: 'tiles', assets: RK.isoMedievalAssets });
RK.registerPack({ id: 'kenney-3d-characters', label: 'Animated 3D', projection: '3d', kind: 'skin', assets: RK.threeAssets });
diff --git a/html/js/render/renderModes.js b/html/js/render/renderModes.js
index fdc34c2..1bdff4c 100644
--- a/html/js/render/renderModes.js
+++ b/html/js/render/renderModes.js
@@ -6,13 +6,14 @@
// Render tiers. `premium` marks modes intended to be unlocked with credits — a cosmetic
// entitlement gated by Operator Policy, the same pattern as avatar unlocks. NOTE: rendering
// is client-side, so this is a soft/honour gate for cosmetics, not a hard security boundary.
+ // Modes are TECHNIQUES (how a scene is drawn). Packs are the SETS/styles within a projection
+ // (chosen via the pack picker) — so "Fancy" (tiles + FX) is a topdown PACK, not a mode. A
+ // premium mode unlocks once the user has ANY unlocked pack for its projection.
RK.RENDER_MODES = [
- { id: 'tiles', label: 'Tiled', premium: false },
- { id: 'ascii', label: 'ASCII', premium: false },
- { id: 'fancy', label: 'Fancy', premium: true, pack: 'generated-skins' },
- { id: 'iso', label: 'Iso', premium: true, pack: 'iso-dungeon' },
- { id: '3d', label: '3D', premium: true, pack: 'kenney-3d-characters' }
- // Planned: 'fancy-ascii' — shader-lit, animated glyphs.
+ { id: 'tiles', label: 'Tiled', premium: false, projection: 'topdown' },
+ { id: 'ascii', label: 'ASCII', premium: false, projection: 'topdown' },
+ { id: 'iso', label: 'Iso', premium: true, projection: 'iso' },
+ { id: '3d', label: '3D', premium: true, projection: '3d' }
];
RK.entitlements = RK.entitlements || { premium: false, level: 'free', packs: {} };
@@ -39,8 +40,11 @@
var m = RK.modeMeta(id);
if (!m) return false;
if (RK.renderModeTestUnlocks && RK.renderModeTestUnlocks()) return true;
+ if (!m.premium) return true;
+ // A premium mode is available once the user has ANY unlocked pack for its projection.
+ if (m.projection && RK.unlockedPacks) return RK.unlockedPacks(m.projection).length > 0;
if (m.pack && RK.canUsePack) return RK.canUsePack(m.pack);
- return !m.premium || !!RK.entitlements.premium;
+ return !!RK.entitlements.premium;
};
RK.createRenderer = function (mode, host, opts) {
diff --git a/src/migrations/027_roguelike_dungeon_pack.sql b/src/migrations/027_roguelike_dungeon_pack.sql
new file mode 100644
index 0000000..63b4bc9
--- /dev/null
+++ b/src/migrations/027_roguelike_dungeon_pack.sql
@@ -0,0 +1,8 @@
+-- 027: add the Roguelike Dungeon topdown tile pack as a catalog product.
+-- Same sheet, a dungeon palette (a distinct 2D tileset). Cheap topdown-variety tier: 1 credit /
+-- supporter (tier 1). Idempotent.
+INSERT INTO cosmetic_catalog (pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, active, sort_order, metadata)
+VALUES ('roguelike-dungeon', 'Roguelike Dungeon (Tiles)', 'render-pack', 'topdown', 1, 1, false, true, 15, '{}'::jsonb)
+ON CONFLICT (pack_id) DO UPDATE
+ SET label = EXCLUDED.label, projection = EXCLUDED.projection, tier = EXCLUDED.tier,
+ unlock_min_credits = EXCLUDED.unlock_min_credits, active = true;
diff --git a/src/multiplayer/entitlements.js b/src/multiplayer/entitlements.js
index 6e01897..656afff 100644
--- a/src/multiplayer/entitlements.js
+++ b/src/multiplayer/entitlements.js
@@ -21,6 +21,10 @@ const DEFAULT_CATALOG = Object.freeze({
id: 'generated-skins', label: 'Premium Generated Skins', kind: 'render-pack',
projection: 'topdown', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true
}),
+ 'roguelike-dungeon': Object.freeze({
+ id: 'roguelike-dungeon', label: 'Roguelike Dungeon (Tiles)', kind: 'render-pack',
+ projection: 'topdown', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true
+ }),
'iso-dungeon': Object.freeze({
id: 'iso-dungeon', label: 'Kenney Isometric Dungeon', kind: 'render-pack',
projection: 'iso', tier: 2, unlockMinCredits: 10, grantOnly: false, premium: true
diff --git a/test/renderPackResolver.test.js b/test/renderPackResolver.test.js
index ad16209..002123e 100644
--- a/test/renderPackResolver.test.js
+++ b/test/renderPackResolver.test.js
@@ -105,10 +105,11 @@ describe('render pack visual resolver', () => {
expect(RK.isoAssets.characters['char-merchant']).toBeUndefined();
expect(RK.isoAssets.characters['char-wizard']).toBeUndefined();
expect(RK.isoAssets.characters['char-goblin']).toBeUndefined();
- expect(RK.isoAssets.directions.down.idle).toContain('Male_3_Idle0.png');
- expect(RK.isoAssets.directions.up.idle).toContain('Male_7_Idle0.png');
+ // 45° renders: 0=NW,2=SW,4=SE,6=NE. Facing follows visual movement (up->NE, down->SW, etc.).
+ expect(RK.isoAssets.directions.down.idle).toContain('Male_2_Idle0.png');
+ expect(RK.isoAssets.directions.up.idle).toContain('Male_6_Idle0.png');
expect(RK.isoAssets.directions.left.idle).toContain('Male_0_Idle0.png');
- expect(RK.isoAssets.directions.right.idle).toContain('Male_5_Idle0.png');
+ expect(RK.isoAssets.directions.right.idle).toContain('Male_4_Idle0.png');
});
test('projection visuals expose the saved base tint as a render color', () => {
@@ -142,8 +143,7 @@ describe('render pack visual resolver', () => {
// With test unlocks on, ALL premium modes and packs are available for QA
RK.RENDER_MODE_TEST_UNLOCKS = true;
- expect(RK.canUseMode('fancy')).toBe(true);
- expect(RK.canUseMode('iso')).toBe(true);
+ expect(RK.canUseMode('iso')).toBe(true); // 'fancy' is no longer a mode — it's a topdown pack
expect(RK.canUseMode('3d')).toBe(true);
expect(RK.canUsePack('iso-dungeon')).toBe(true);
expect(RK.canUsePack('kenney-3d-characters')).toBe(true);