Add per-campaign theming system for in-level visuals

- Create `src/data/themes.js` with theme definitions (background color, parallax layers) and a `getTheme()` resolver that falls back to campaign 1's default look
- Update `PlayScene` to load the active campaign's theme instead of hardcoding colors/texture keys
- Register campaign 2's `_2` background assets in the asset manifest with placeholder fallbacks
- Add updated background art for campaign 2 (`bg_far_2.png`)
This commit is contained in:
Brian Fertig 2026-08-23 16:49:09 -06:00
parent a728a51cd2
commit c2abb5c4ea
5 changed files with 80 additions and 9 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@ -11,9 +11,10 @@ import level10 from './level10.js';
import level11 from './level11.js';
// Campaigns group levels into themed "maps" for the level select screen.
// Each campaign gets its own background art (bgKey) and theme, so adding a
// new campaign is: create its levels, add a bg image + manifest entry, add a
// row here. Node positions are in 960x540 design units (NOT multiplied by
// Each campaign gets its own background art (bgKey), menu theme music
// (musicKey), and in-level look (data/themes.js), so adding a new campaign
// is: create its levels, add a bg image + manifest entry, add an entry to
// THEMES, add a row here. Node positions are in 960x540 design units (NOT multiplied by
// WORLD_SCALE - the scene scales them), and the path is rendered as a line
// through them in order. `positions` may be omitted to auto-place the
// campaign's levels: a 2-lane circuit (top lane right->left, bottom lane

57
src/data/themes.js Normal file
View File

@ -0,0 +1,57 @@
// Per-campaign themes for the IN-LEVEL (play) presentation - the parallax
// background stack and base camera color shown while actually driving a
// level. This is distinct from a campaign's *map* art (bgKey) and menu
// music (musicKey), which live on the campaign object in data/levels/index.js.
//
// Adding a new campaign: add one entry below keyed by the campaign's `id`
// (see data/levels/index.js). You may set the whole object or only the bits
// you want to change; any field you leave out (or the entire entry) falls
// back to DEFAULT_THEME - the campaign 1 "Evergreen Lake" look. So a brand
// new campaign is already themed by default and only needs an entry for
// whatever it wants to customize (swap the art, tweak the base color, add a
// new theme axis for this campaign only, etc.).
//
// Every texture key referenced here must exist in util/assetManifest.js so
// PreloadScene loads it (and a placeholder is generated if the file is
// missing).
// The default / campaign 1 look. Other campaigns inherit from this for any
// field they don't override, so this stays the single source for "the
// baseline art".
export const DEFAULT_THEME = {
// Camera background color, drawn behind the parallax stack and only
// visible through gaps. A hex string, the same format the scenes pass to
// Phaser's setBackgroundColor().
bgColor: '#8fc7e8',
// The three parallax layers, back to front. `far` is the full-viewport
// sky; `mid`/`near` are bottom-anchored ground tiles (see PlayScene's
// _buildParallax). Keys are texture keys, not file paths.
parallax: {
far: 'bg_far',
mid: 'bg_mid',
near: 'bg_near',
},
};
export const THEMES = {
// Campaign 1 uses the baseline art as-is.
campaign01: DEFAULT_THEME,
// Campaign 2 ("Underprivileged Community") - the urban _2 background set,
// with a dusk base color matched to bg_far_2's sky.
campaign02: {
bgColor: '#92656d',
parallax: {
far: 'bg_far_2',
mid: 'bg_mid_2',
near: 'bg_near_2',
},
},
// Add campaign03: { ... } here when it's ready.
};
// Resolves the theme for a campaign id, always returning a complete theme
// (falls back to the default for unknown/absent campaigns).
export function getTheme(campaignId) {
return THEMES[campaignId] || DEFAULT_THEME;
}

View File

@ -1,6 +1,7 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, DEBUG, WORLD_SCALE, COMPARTMENT_DEBUG } from '../config.js';
import { getLevelById } from '../data/levels/index.js';
import { getLevelById, getCampaignOfLevel } from '../data/levels/index.js';
import { getTheme } from '../data/themes.js';
import Bus from '../entities/Bus.js';
import Terrain from '../entities/Terrain.js';
import FinishLine from '../entities/FinishLine.js';
@ -33,7 +34,13 @@ export default class PlayScene extends Phaser.Scene {
create() {
stopMenuMusic(this);
this.cameras.main.setBackgroundColor('#8fc7e8');
// Per-campaign theme (data/themes.js) - drives the base color and the
// parallax art below. Campaign 1 is the default look; each new campaign
// can override any of it.
const campaign = getCampaignOfLevel(this.levelId);
this.theme = getTheme(campaign ? campaign.id : null);
this.cameras.main.setBackgroundColor(this.theme.bgColor);
this._buildParallax();
@ -111,9 +118,9 @@ export default class PlayScene extends Phaser.Scene {
// manually scrubs its tilePositionX each frame based on progress from
// start to goal, so the single image pans start-to-end exactly once
// over the course of the level.
const farNative = this._nativeTextureSize('bg_far');
const farNative = this._nativeTextureSize(this.theme.parallax.far);
const farScale = GAME_HEIGHT / farNative.height;
this.bgFar = this.add.tileSprite(0, 0, GAME_WIDTH, GAME_HEIGHT, 'bg_far').setOrigin(0, 0);
this.bgFar = this.add.tileSprite(0, 0, GAME_WIDTH, GAME_HEIGHT, this.theme.parallax.far).setOrigin(0, 0);
this.bgFar.setScrollFactor(0, 0);
this.bgFar.tileScaleX = farScale;
this.bgFar.tileScaleY = farScale;
@ -125,8 +132,8 @@ export default class PlayScene extends Phaser.Scene {
// count isn't pinned to an exact number - it falls out of each layer's
// target height, and near's is shorter than mid's, so it naturally
// repeats more often across the same span.
this._addGroundLayer('bg_mid', bounds.x, width, GAME_HEIGHT * 0.6, 0.45);
this._addGroundLayer('bg_near', bounds.x, width, GAME_HEIGHT * 0.35, 0.75);
this._addGroundLayer(this.theme.parallax.mid, bounds.x, width, GAME_HEIGHT * 0.6, 0.45);
this._addGroundLayer(this.theme.parallax.near, bounds.x, width, GAME_HEIGHT * 0.35, 0.75);
}
_nativeTextureSize(key) {

View File

@ -22,9 +22,15 @@ export const ASSET_MANIFEST = [
// 9 frames of 64x64 bonus items (CTC box + 2 firework frames, guitar + 2,
// cash + 2 - see BONUS_ITEM in config.js and BonusItemManager.js).
{ key: 'bonus_items', path: 'assets/sprites/bonus-items.png', width: 64 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0xe0a040, frameWidth: 64, frameHeight: 64 },
// Campaign 1 ("Evergreen Lake") parallax set - see DEFAULT_THEME in
// data/themes.js. The _2 set is campaign 2's; both are 3136x672 natively
// (width/height below only feed the placeholder fallback, never display).
{ key: 'bg_far', path: 'assets/backgrounds/bg_far.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8, tileable: true },
{ key: 'bg_mid', path: 'assets/backgrounds/bg_mid.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x6fae7d, tileable: true },
{ key: 'bg_near', path: 'assets/backgrounds/bg_near.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, tileable: true },
{ key: 'bg_far_2', path: 'assets/backgrounds/bg_far_2.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x92656d, tileable: true },
{ key: 'bg_mid_2', path: 'assets/backgrounds/bg_mid_2.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x644c44, tileable: true },
{ key: 'bg_near_2', path: 'assets/backgrounds/bg_near_2.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x554239, tileable: true },
// Intro screen's poster art (960x540 native design aspect) and the
// standalone wordmark graphic IntroScene layers on top of it, animated -
// see IntroScene._buildLogo.