Files
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

470 lines
22 KiB
JavaScript

// Themes / packs / entitlement policy for the render kit. Packs are resolved by capability
// (top-down, isometric, 3D) so the same `appearance` identity can render through different
// visual projections without changing Room state or join flow.
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
RK.TILE_KINDS = ['floor', 'wall', 'bar', 'table'];
var FREE_EQUIPMENT = { body: 'none', head: 'none', shield: 'none', weapon: 'none' };
var FREE_COLORS = { base: 'none', skin: 'natural', hair: 'copper', body: 'none', head: 'none', shield: 'none', weapon: 'none' };
RK.PACKS = {
'roguelike-interior': {
id: 'roguelike-interior',
label: 'Kenney Roguelike Interior',
premium: false,
projection: 'topdown'
},
'iso-dungeon': {
id: 'iso-dungeon',
label: 'Kenney Isometric Dungeon',
premium: true,
projection: 'iso',
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
},
'kenney-3d-characters': {
id: 'kenney-3d-characters',
label: 'Kenney Animated 3D Characters',
premium: true,
projection: '3d',
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
},
'generated-skins': {
id: 'generated-skins',
label: 'Premium Generated Skins',
premium: true,
projection: 'topdown',
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
}
};
RK.entitlements = RK.entitlements || { premium: false, level: 'free', packs: {}, credits: 0, totalCreditsPurchased: 0 };
RK.setEntitlementSnapshot = function (data) {
data = data || {};
var prev = RK.entitlements || {};
// The server catalog is authoritative over the client pack list: operator-added packs
// appear, and existing packs pick up server definitions (label/projection/tier). Merged
// rather than replaced so a client fallback a render mode relies on survives an operator
// catalog that omits it.
if (Array.isArray(data.catalog) && data.catalog.length) {
var merged = {};
for (var k in RK.PACKS) merged[k] = RK.PACKS[k];
for (var ci = 0; ci < data.catalog.length; ci++) {
var c = data.catalog[ci];
if (!c || !c.id) continue;
var ex = merged[c.id] || {};
merged[c.id] = {
id: c.id,
label: c.label || ex.label || c.id,
premium: c.premium != null ? !!c.premium : !!ex.premium,
projection: c.projection || ex.projection || null,
tier: c.tier != null ? c.tier : ex.tier,
unlockMinCredits: c.unlockMinCredits != null ? c.unlockMinCredits : ex.unlockMinCredits,
unlock: ex.unlock
};
}
RK.PACKS = merged;
}
var hasTotal = data.totalCreditsPurchased != null || data.total_credits_purchased != null;
var hasPremium = data.premium != null;
var hasPacks = !!data.packs;
var hasCredits = data.credits != null || data.balance != null;
var total = hasTotal
? Number(data.totalCreditsPurchased || data.total_credits_purchased || 0)
: Number(prev.totalCreditsPurchased || 0);
var credits = hasCredits
? Number(data.credits != null ? data.credits : data.balance)
: Number(prev.credits || 0);
var premium = hasPremium ? !!data.premium : (hasTotal ? total > 0 : !!prev.premium);
var packs = {};
for (var id in RK.PACKS) {
var p = RK.PACKS[id];
if (!p.premium) {
packs[id] = true;
} else if (hasPacks && Object.prototype.hasOwnProperty.call(data.packs, id)) {
packs[id] = !!data.packs[id];
} else if (!hasTotal && !hasPacks && prev.packs && Object.prototype.hasOwnProperty.call(prev.packs, id)) {
packs[id] = !!prev.packs[id];
} else {
packs[id] = premium;
}
}
for (var pid in packs) {
if (packs[pid] && RK.PACKS[pid] && RK.PACKS[pid].premium) premium = true;
}
RK.entitlements = {
premium: premium,
level: data.level || (premium ? ((prev.level && prev.level !== 'free') ? prev.level : 'credits') : 'free'),
packs: packs,
credits: credits,
totalCreditsPurchased: total
};
try { localStorage.setItem('rk_entitlements', JSON.stringify(RK.entitlements)); } catch (_) {}
return RK.entitlements;
};
RK.loadEntitlements = function () {
try {
var raw = JSON.parse(localStorage.getItem('rk_entitlements') || 'null');
if (raw) return RK.setEntitlementSnapshot(raw);
} catch (_) {}
return RK.entitlements;
};
RK.loadEntitlements();
RK.pack = function (id) { return RK.PACKS[id] || null; };
RK.canUsePack = function (id) {
var p = RK.pack(id);
if (!p) return false;
if (!p.premium) return true;
// Production QA switch: when enabled, all premium packs are available for testing.
if (RK.renderModeTestUnlocks && RK.renderModeTestUnlocks()) return true;
return !!(RK.entitlements && RK.entitlements.packs && RK.entitlements.packs[id]);
};
RK.canUseAppearance = function (a) {
if (!a || !a.premium) return true;
return a.pack ? RK.canUsePack(a.pack) : !!(RK.entitlements && RK.entitlements.premium);
};
RK.THEMES = {
// Free baseline pack: the game's bare tiles. Only the essentials are mapped
// (floor/wall/door/torch/features); furniture kinds (bar/keg/table/chair, ...) are absent,
// so they fall back to floor and the free tavern reads plain, which is the reason to unlock.
'original': {
id: 'original',
label: 'Original Tiles',
// html/tiles.png, 32px, no spacing: the same tiles the ROT instructions and waiting
// screens use, so the free pack carries the classic look. Grid coords are the legacy
// pixel coords divided by 32 (see options.js tileMap).
tileset: { url: 'tiles.png', tile: 32, spacing: 0 },
tiles: {
floor: [0, 0],
floor2: [32, 0], // alt/dirt floor
wall: [2, 1], // stone brick wall
door: [1, 0], // wooden door
entrance: [0, 1],
exit: [1, 0],
treasure: [30, 0], // $W gold coin
torch: [32, 1],
// The tiled renderer prefers a pack's player/monster tile over the sprite when
// present: the blue-robe @ hero and the green ~ goblin.
player: [2, 0],
monster: [31, 1]
// No furniture kinds, so tavern furniture falls back to floor.
}
},
'roguelike-interior': {
id: 'roguelike-interior',
label: 'Kenney Roguelike Interior',
tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
// Default tile picks from the sheet metadata (roguelikeSheet_meta.json).
// The tile-picker overlay can override these per-user via localStorage.
// Each kind maps to a single [col,row] on the sheet (cols = 57). One coherent tile per
// kind: mixing wood/stone/plank variants per cell reads as noise. TileRenderer still
// accepts an array here for a theme wanting subtle, same-material variation.
tiles: {
floor: [5, 2], // 119 wood floor
floor2: [6, 2], // 120 stone floor (accent)
wall: [13, 12], // 697 stone masonry
// Tavern furnishings (coords from roguelikeSheet_meta.json labels: index -> [i%57, i/57]).
window: [42, 2], // 156 window (in the wall)
bar: [29, 0], // 29 bar counter
keg: [23, 0], // 23 barrel / beer keg (behind the bar)
shelf: [30, 1], // 87 shelf of bottles (behind the bar)
table: [20, 3], // 191 table, reads wooden; 312 renders green
chair: [19, 3], // 190 chair
barrel: [23, 0], // 23 barrel (decor)
crate: [25, 0], // 25 crate (decor)
rug: [11, 16], // 923 rug
door: [37, 9], // 550 door
// Dungeon features drawn as real tiles instead of monospace glyphs.
entrance: [37, 9], // 550 door
exit: [37, 9], // 550 door
treasure: [42, 15], // 897 item
torch: [50, 10] // 620 rubble / prop
}
},
// Second topdown pack: same sheet, dungeon palette (stone floors, barrels/crates instead of
// the wood-interior furniture). A distinct look with zero new assets; the tiled renderer's
// lighting/shadows supply the 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';
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) {
var t = RK.theme(id); if (!t) return {};
var map = {};
for (var k in t.tiles) map[k] = t.tiles[k];
try {
var ov = JSON.parse(localStorage.getItem('rk_tilemap_' + t.id) || '{}');
for (var kk in ov) map[kk] = ov[kk];
} catch (_) { /* ignore */ }
return map;
};
RK.setTile = function (id, kind, col, row) {
var t = RK.theme(id); if (!t) return;
var key = 'rk_tilemap_' + t.id, ov = {};
try { ov = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {}
ov[kind] = [col, row];
try { localStorage.setItem(key, JSON.stringify(ov)); } catch (_) {}
};
// Lazy tileset atlas per theme.
var atlases = {};
RK.themeAtlas = function (id) {
var t = RK.theme(id); if (!t || !t.tileset) return null;
if (!atlases[t.id]) {
atlases[t.id] = new RK.TilesetAtlas(t.tileset.url, { tile: t.tileset.tile, spacing: t.tileset.spacing });
}
return atlases[t.id];
};
RK.loadTheme = function (id, cb) {
var a = RK.themeAtlas(id);
if (!a) { if (cb) cb(null); return; }
a.onReady(function () { if (cb) cb(a); });
};
RK.EXTRA_APPEARANCES = {
'kenney-survivor-male': {
id: 'kenney-survivor-male',
label: 'Survivor',
kind: 'model3d',
premium: true,
pack: 'kenney-3d-characters'
},
'kenney-survivor-female': {
id: 'kenney-survivor-female',
label: 'Survivor Scout',
kind: 'model3d',
premium: true,
pack: 'kenney-3d-characters'
}
};
function normalizeEquipment(eq) {
eq = eq || {};
var out = {};
for (var k in FREE_EQUIPMENT) out[k] = eq[k] || 'none';
return out;
}
function normalizeColors(colors, tint) {
if (RK.normalizeCharColors) return RK.normalizeCharColors(colors, tint);
colors = colors || {};
var out = {};
for (var k in FREE_COLORS) out[k] = colors[k] || (k === 'base' ? (tint || FREE_COLORS[k]) : FREE_COLORS[k]);
return out;
}
RK.packAppearances = function () {
var out = [];
for (var id in RK.EXTRA_APPEARANCES) out.push(RK.EXTRA_APPEARANCES[id]);
return out;
};
function isoMale(n) {
var base = 'assets/kenney/iso-dungeon/characters/male' + n + '/Male_' + n + '_';
var run = [];
for (var i = 0; i < 10; i++) run.push(base + 'Run' + i + '.png');
return {
idle: base + 'Idle0.png',
run: run,
imageW: 48,
imageH: 92
};
}
RK.isoAssets = {
pack: 'iso-dungeon',
avatar: 'char-villager',
tile: { w: 84, h: 42, imageW: 92, imageH: 184 },
tiles: {
// Floors / ground
floor: 'assets/kenney/iso-dungeon/tiles/planks_S.png', // wood, tavern interior
floor2: 'assets/kenney/iso-dungeon/tiles/stone_S.png', // stone, dungeon
dirt: 'assets/kenney/iso-dungeon/tiles/dirt_S.png',
rug: 'assets/kenney/iso-dungeon/tiles/planks_S.png',
// Architecture (full-height tiles). Iso walls have a facing: a wall running along the
// grid-x axis needs the _S rotation, one running along grid-y needs the perpendicular
// _W rotation, and true corners get the corner tile. The renderer picks per-cell from
// neighbours, choosing `<kind>Y` for y-running segments; without that the top-left and
// bottom-right edges read as thin slabs.
wall: 'assets/kenney/iso-dungeon/tiles/stoneWall_S.png',
wallY: 'assets/kenney/iso-dungeon/tiles/stoneWall_W.png',
wallCorner: 'assets/kenney/iso-dungeon/tiles/stoneWallCorner_S.png',
window: 'assets/kenney/iso-dungeon/tiles/stoneWallWindow_S.png',
windowY: 'assets/kenney/iso-dungeon/tiles/stoneWallWindow_W.png',
door: 'assets/kenney/iso-dungeon/tiles/stoneWallDoorOpen_S.png',
doorY: 'assets/kenney/iso-dungeon/tiles/stoneWallDoorOpen_W.png',
archway: 'assets/kenney/iso-dungeon/tiles/stoneWallArchway_S.png',
// Floor variety: the renderer sprinkles these deterministically so the ground isn't a
// flat sea of identical planks.
floorAlt: 'assets/kenney/iso-dungeon/tiles/planksBroken_S.png',
floor2Alt: 'assets/kenney/iso-dungeon/tiles/stoneUneven_S.png',
stoneTile: 'assets/kenney/iso-dungeon/tiles/stoneTile_S.png',
column: 'assets/kenney/iso-dungeon/tiles/stoneColumnWood_S.png',
stairs: 'assets/kenney/iso-dungeon/tiles/stairs_S.png',
// Furniture props, drawn over a floor base; each kind has distinct art.
bar: 'assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png', // stacked barrels as the bar counter
keg: 'assets/kenney/iso-dungeon/tiles/barrel_S.png',
barrel: 'assets/kenney/iso-dungeon/tiles/barrel_S.png',
table: 'assets/kenney/iso-dungeon/tiles/tableShortChairs_S.png', // real table + chairs
shelf: 'assets/kenney/iso-dungeon/tiles/woodenCrates_S.png',
crate: 'assets/kenney/iso-dungeon/tiles/woodenCrate_S.png',
chair: 'assets/kenney/iso-dungeon/tiles/chair_S.png',
chest: 'assets/kenney/iso-dungeon/tiles/chestClosed_S.png',
fallback: 'assets/kenney/iso-dungeon/tiles/planks_S.png'
},
// The Kenney files named Male_0..Male_7 are directional renders of the same body, not
// different character classes, so the customizer exposes one Iso body. The sprites are
// angled clockwise from NE: 0=NE, 2=SE, 4=SW, 6=NW. In this iso projection a grid step
// moves visually up->NE, down->SW, left->NW, right->SE, so each facing uses the sprite
// pointing that way and the character faces its direction of travel.
character: isoMale(3), // idle faces roughly toward the camera (S)
directions: {
up: isoMale(0), // NE (up-right)
down: isoMale(4), // SW (down-left)
left: isoMale(6), // NW (up-left)
right: isoMale(2) // SE (down-right)
},
characters: {
fallback: isoMale(3),
'char-villager': isoMale(3)
}
};
// Second iso pack: Kenney Isometric Medieval Town, a separate environment. Strong on
// architecture (floor/walls/door/window/banner); it has no medieval furniture at this scale, so
// furniture kinds fall back to floor. Reuses the dungeon-pack characters. Its tiles are shorter
// (210x244) than the dungeon set, hence its own tile geometry.
RK.isoMedievalAssets = {
pack: 'iso-medieval',
avatar: 'char-villager',
tile: { w: 84, h: 42, imageW: 84, imageH: 98 },
tiles: {
floor: 'assets/kenney/iso-medieval/floor.png',
floor2: 'assets/kenney/iso-medieval/floor2.png',
wall: 'assets/kenney/iso-medieval/wall.png',
window: 'assets/kenney/iso-medieval/window.png',
door: 'assets/kenney/iso-medieval/door.png',
banner: 'assets/kenney/iso-medieval/banner.png',
fallback: 'assets/kenney/iso-medieval/floor.png'
},
character: RK.isoAssets.character,
directions: RK.isoAssets.directions,
characters: RK.isoAssets.characters
};
RK.threeAssets = {
pack: 'kenney-3d-characters',
models: {
'kenney-survivor-male': {
url: 'assets/generated/3d/kenney-animated-characters/survivorMaleB.glb',
clips: { idle: 'idle', run: 'run', jump: 'jump' }
},
'kenney-survivor-female': {
url: 'assets/generated/3d/kenney-animated-characters/survivorFemaleA.glb',
clips: { idle: 'idle', run: 'run', jump: 'jump' }
},
fallback: {
url: 'assets/generated/3d/kenney-animated-characters/survivorMaleB.glb',
clips: { idle: 'idle', run: 'run', jump: 'jump' }
}
}
};
// Register the built-in render packs in the pack registry (packRegistry.js) so iso and 3D are
// multi-pack like topdown themes. Each maps to its catalog id; the active pack per projection
// is entitlement-gated and user-selectable. Adding a pack takes registerPack(...), assets, and
// a catalog row. With one pack per projection, resolution falls through to these.
if (RK.registerPack) {
RK.registerPack({ id: 'original', label: 'Original Tiles', projection: 'topdown', kind: 'tiles', assets: RK.THEMES['original'] });
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 });
}
// 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];
}
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') {
return {
projection: 'iso',
pack: 'iso-dungeon',
avatar: avatar,
tint: appearance.tint || 'none',
equipment: normalizeEquipment(appearance.equipment),
colors: normalizeColors(appearance.colors, appearance.tint),
character: RK.isoAssets.character
};
}
if (projection === '3d') {
return {
projection: '3d',
pack: 'kenney-3d-characters',
avatar: avatar,
model: RK.threeAssets.models[avatar] || RK.threeAssets.models.fallback,
tint: appearance.tint || 'none',
equipment: normalizeEquipment(appearance.equipment),
colors: normalizeColors(appearance.colors, appearance.tint)
};
}
return { projection: 'topdown', avatar: avatar, appearance: appearance };
};
})(window);