feat: add colored playfield theme support with dynamic color schemes

- Add new "Colored" playfield type that generates textures from JSON-defined color schemes
- Create colored-playfields.json with 10 curated color themes (Midnight Aurora, Sunset Horizon, etc.)
- Add dropdown selector in OpponentSelectScene to choose between color schemes
- Generate colored playfield textures dynamically using canvas gradients at game start
- Display theme preview in playfield selection UI with CSS gradient rendering
- Integrate colored playfield loading and default application alongside existing playfields
This commit is contained in:
Brian Fertig 2026-06-13 09:04:42 -06:00
parent 09d94a6ae7
commit 6440328f2c
4 changed files with 276 additions and 4 deletions

View File

@ -0,0 +1,124 @@
{
"schemes": [
{
"id": "midnight-aurora",
"name": "Midnight Aurora",
"base": "#0d0d2b",
"accents": ["#00ffe1", "#7b2fff", "#0099ff", "#ff2d78"]
},
{
"id": "sunset-horizon",
"name": "Sunset Horizon",
"base": "#2b0a0a",
"accents": ["#ff6b47", "#ffb347", "#ff4d7e", "#ffd700"]
},
{
"id": "forest-glade",
"name": "Forest Glade",
"base": "#071a0e",
"accents": ["#7fff00", "#87a878", "#2e8b57", "#d4af37"]
},
{
"id": "deep-ocean",
"name": "Deep Ocean",
"base": "#041520",
"accents": ["#00e5ff", "#4169e1", "#20b2aa", "#7fb3d3"]
},
{
"id": "desert-dusk",
"name": "Desert Dusk",
"base": "#1f1208",
"accents": ["#c04a1a", "#e8a020", "#fa8072", "#d4701a"]
},
{
"id": "royal-purple",
"name": "Royal Purple",
"base": "#150820",
"accents": ["#dd00ff", "#0047ab", "#b57bee", "#ffd700"]
},
{
"id": "dragon-fire",
"name": "Dragon Fire",
"base": "#120404",
"accents": ["#dc143c", "#ff6600", "#ffd700"]
},
{
"id": "arctic-frost",
"name": "Arctic Frost",
"base": "#0a1020",
"accents": ["#9bb7d4", "#b0e0e6", "#b0c4de", "#ddeeff"]
},
{
"id": "mocha-cafe",
"name": "Mocha Café",
"base": "#0f0a06",
"accents": ["#cd7f32", "#8b2020", "#d4801a", "#f5deb3"]
},
{
"id": "emerald-isle",
"name": "Emerald Isle",
"base": "#030f08",
"accents": ["#50c878", "#2e8b57", "#00a86b", "#98ff98"]
},
{
"id": "neon-city",
"name": "Neon City",
"base": "#050510",
"accents": ["#ff0099", "#00ffcc", "#7b00ff", "#ffee00"]
},
{
"id": "cherry-blossom",
"name": "Cherry Blossom",
"base": "#1a0a10",
"accents": ["#ffb7c5", "#ff8fa3", "#e8cfe0", "#fce4ec"]
},
{
"id": "volcanic",
"name": "Volcanic",
"base": "#080200",
"accents": ["#7f0000", "#dd2200", "#ff6600"]
},
{
"id": "galactic",
"name": "Galactic",
"base": "#000008",
"accents": ["#6a1b9a", "#1a237e", "#e8eaf6", "#880e4f"]
},
{
"id": "autumn-harvest",
"name": "Autumn Harvest",
"base": "#0f0400",
"accents": ["#a63200", "#7a0c00", "#d48000", "#c62828"]
},
{
"id": "bioluminescence",
"name": "Bioluminescence",
"base": "#000d14",
"accents": ["#00e5ff", "#76ff03", "#f0f4f8"]
},
{
"id": "candy-pop",
"name": "Candy Pop",
"base": "#0d000d",
"accents": ["#ff1493", "#00e676", "#ff9100", "#29b6f6"]
},
{
"id": "copper-patina",
"name": "Copper Patina",
"base": "#060a04",
"accents": ["#b87333", "#45b08c", "#8b6914", "#d4a853"]
},
{
"id": "sakura-night",
"name": "Sakura Night",
"base": "#07030f",
"accents": ["#e91e8c", "#ce93d8", "#ffe082"]
},
{
"id": "silver-storm",
"name": "Silver Storm",
"base": "#080808",
"accents": ["#546e7a", "#90a4ae", "#cfd8dc", "#eceff1"]
}
]
}

View File

@ -1,6 +1,12 @@
{ {
"default": "cherry", "default": "colored",
"playfields": [ "playfields": [
{
"id": "colored",
"name": "Colored",
"type": "colored",
"fallbackColor": "#1a1a3a"
},
{ {
"id": "cherry", "id": "cherry",
"name": "Cherry Wooden Table", "name": "Cherry Wooden Table",
@ -34,7 +40,7 @@
"fallbackColor": "#14532d" "fallbackColor": "#14532d"
}, },
{ {
"id": "felt", "id": "blue",
"name": "Blue Felt", "name": "Blue Felt",
"key": "playfield-blue", "key": "playfield-blue",
"path": "/assets/images/playfield-felt-blue.png", "path": "/assets/images/playfield-felt-blue.png",

View File

@ -41,6 +41,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.selectedDifficulty = 'novice'; // Forbidden Island water-level start this.selectedDifficulty = 'novice'; // Forbidden Island water-level start
this._initializing = false; this._initializing = false;
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only) this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
this.selectedColorScheme = null;
this._colorDropdownDomEl = null;
this._coloredThumbDomEl = null;
} }
async create() { async create() {
@ -135,8 +138,20 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (this.gameDef.slug === 'forbiddenisland') this.buildDifficultySection(340, 1013); if (this.gameDef.slug === 'forbiddenisland') this.buildDifficultySection(340, 1013);
if (!isWordGame && this.gameDef.slug !== 'battleship' && this.gameDef.slug !== 'mastermind') { if (!isWordGame && this.gameDef.slug !== 'battleship' && this.gameDef.slug !== 'mastermind') {
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [], const pfItems = this.cache.json.get('playfields')?.playfields ?? [];
const coloredIdx = pfItems.findIndex(p => p.type === 'colored');
if (coloredIdx >= 0) {
const schemes = this.cache.json.get('colored-playfields')?.schemes ?? [];
if (schemes.length) this.selectedColorScheme = schemes[Math.floor(Math.random() * schemes.length)];
}
this.buildOptionSection('Playfield', 630, pfItems,
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf)); 'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
if (coloredIdx >= 0) {
const totalW = pfItems.length * TILE_W + (pfItems.length - 1) * TILE_GAP;
const tileX = GAME_WIDTH / 2 - totalW / 2 + TILE_W / 2 + coloredIdx * (TILE_W + TILE_GAP);
const tileBottomY = 630 + Math.max(82, Math.round(TILE_H / 2) + 18) + TILE_H / 2;
this.buildColorSchemeDropdown(tileX, tileBottomY + 28);
}
} }
if (this.gameDef.cardGame && this.gameDef.slug !== 'splendor') { if (this.gameDef.cardGame && this.gameDef.slug !== 'splendor') {
@ -149,6 +164,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (!isWordGame && this.gameDef.slug !== 'battleship' && this.gameDef.slug !== 'mastermind') { if (!isWordGame && this.gameDef.slug !== 'battleship' && this.gameDef.slug !== 'mastermind') {
const pfd = this.cache.json.get('playfields') ?? {}; const pfd = this.cache.json.get('playfields') ?? {};
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles'); this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles');
if (this.selectedPlayfield?.type === 'colored' && this._colorDropdownDomEl) {
this._colorDropdownDomEl.node.style.display = 'block';
}
} }
if (this.gameDef.cardGame && this.gameDef.slug !== 'splendor') { if (this.gameDef.cardGame && this.gameDef.slug !== 'splendor') {
const cardBacks = this.cache.json.get('card-backs')?.cardBacks ?? []; const cardBacks = this.cache.json.get('card-backs')?.cardBacks ?? [];
@ -1008,6 +1026,12 @@ export default class OpponentSelectScene extends Phaser.Scene {
thumb = this.add.image(0, -8, thumbKey, frame) thumb = this.add.image(0, -8, thumbKey, frame)
.setDisplaySize(thumbW, thumbH) .setDisplaySize(thumbW, thumbH)
.setOrigin(0.5); .setOrigin(0.5);
} else if (item.type === 'colored') {
const thumbDiv = document.createElement('div');
thumbDiv.style.cssText = `width:${thumbW}px; height:${thumbH}px; border-radius:2px; pointer-events:none;`;
this._applyColorSchemeCss(thumbDiv, this.selectedColorScheme);
this._coloredThumbDomEl = this.add.dom(x, y - 8, thumbDiv);
thumb = this.add.rectangle(0, -8, thumbW, thumbH, 0x000000, 0);
} else { } else {
const fallbackHex = item.fallbackColor ?? '#1a3a6b'; const fallbackHex = item.fallbackColor ?? '#1a3a6b';
const fallback = parseInt(fallbackHex.replace('#', ''), 16); const fallback = parseInt(fallbackHex.replace('#', ''), 16);
@ -1069,15 +1093,132 @@ export default class OpponentSelectScene extends Phaser.Scene {
} }
} }
selectPlayfield(pf) { this.selectedPlayfield = pf; } selectPlayfield(pf) {
this.selectedPlayfield = pf;
if (this._colorDropdownDomEl) {
this._colorDropdownDomEl.node.style.display = pf?.type === 'colored' ? 'block' : 'none';
}
}
selectCardBack(cb) { this.selectedCardBack = cb; } selectCardBack(cb) { this.selectedCardBack = cb; }
// ── Colored playfield support ──────────────────────────────────────────────
buildColorSchemeDropdown(x, y) {
const schemes = this.cache.json.get('colored-playfields')?.schemes ?? [];
if (!schemes.length) return;
const wrapper = document.createElement('div');
wrapper.style.cssText = 'display:none; text-align:center;';
const label = document.createElement('span');
label.textContent = 'Color Scheme:';
label.style.cssText = `
font-family: "Julius Sans One", sans-serif;
font-size: 15px;
color: #9e9080;
margin-right: 10px;
vertical-align: middle;
`;
const select = document.createElement('select');
select.style.cssText = `
background: #1e1a12;
color: #f2ead8;
border: 2px solid #9e9080;
border-radius: 6px;
padding: 6px 14px;
font-family: "Julius Sans One", sans-serif;
font-size: 15px;
cursor: pointer;
outline: none;
vertical-align: middle;
`;
select.addEventListener('mouseover', () => { select.style.borderColor = '#c8a84b'; });
select.addEventListener('mouseout', () => { select.style.borderColor = '#9e9080'; });
schemes.forEach(scheme => {
const option = document.createElement('option');
option.value = scheme.id;
option.textContent = scheme.name;
select.appendChild(option);
});
select.value = this.selectedColorScheme?.id ?? schemes[0].id;
select.addEventListener('change', (e) => {
const scheme = schemes.find(s => s.id === e.target.value) ?? schemes[0];
this.selectedColorScheme = scheme;
if (this._coloredThumbDomEl) this._applyColorSchemeCss(this._coloredThumbDomEl.node, scheme);
});
wrapper.appendChild(label);
wrapper.appendChild(select);
this._colorDropdownDomEl = this.add.dom(x, y, wrapper);
}
_applyColorSchemeCss(el, scheme) {
if (!scheme) return;
const n = scheme.accents.length;
const bands = scheme.accents.map((hex, i) => {
const start = ((i / n) * 100).toFixed(1);
const peak = (((i + 0.5) / n) * 100).toFixed(1);
const end = (((i + 1) / n) * 100).toFixed(1);
return `linear-gradient(135deg, transparent ${start}%, ${hex}cc ${peak}%, transparent ${end}%)`;
});
el.style.backgroundColor = scheme.base;
el.style.backgroundImage = bands.join(', ');
}
generateColoredTexture(scheme, texKey) {
const W = GAME_WIDTH;
const H = GAME_HEIGHT;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = scheme.base;
ctx.fillRect(0, 0, W, H);
const n = scheme.accents.length;
const D = Math.sqrt(W * W + H * H);
const px = -H / D;
const py = W / D;
scheme.accents.forEach((hex, i) => {
const t = (i + 1) / (n + 1);
const gx = W * t;
const gy = H * (1 - t);
const spread = D * 0.55;
const grad = ctx.createLinearGradient(
gx - px * spread, gy - py * spread,
gx + px * spread, gy + py * spread
);
grad.addColorStop(0, hex + '00');
grad.addColorStop(0.5, hex + '80');
grad.addColorStop(1, hex + '00');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
});
if (this.textures.exists(texKey)) this.textures.remove(texKey);
this.textures.addCanvas(texKey, canvas);
}
// ── Start game ───────────────────────────────────────────────────────────── // ── Start game ─────────────────────────────────────────────────────────────
startGame() { startGame() {
if (this.selected.size < (this.gameDef.minOpponents ?? 1)) return; if (this.selected.size < (this.gameDef.minOpponents ?? 1)) return;
this._startingGame = true; this._startingGame = true;
stopMenuMusic(); stopMenuMusic();
if (this.selectedPlayfield?.type === 'colored') {
const scheme = this.selectedColorScheme
?? this.cache.json.get('colored-playfields')?.schemes?.[0];
if (scheme) {
const texKey = `playfield-colored-${scheme.id}`;
this.generateColoredTexture(scheme, texKey);
this.selectedPlayfield = { ...this.selectedPlayfield, key: texKey };
}
}
const opponents = this.cards const opponents = this.cards
.filter(({ opp }) => this.selected.has(opp.id)) .filter(({ opp }) => this.selected.has(opp.id))
.map(({ opp }) => ({ ...opp, skill: this.skillByOpp[opp.id] ?? 3 })); .map(({ opp }) => ({ ...opp, skill: this.skillByOpp[opp.id] ?? 3 }));

View File

@ -58,6 +58,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.image('bg-jewelquest-battle', '/assets/images/background-jewelquest.png'); this.load.image('bg-jewelquest-battle', '/assets/images/background-jewelquest.png');
this.load.image('main-title', '/assets/images/main-title.png'); this.load.image('main-title', '/assets/images/main-title.png');
this.load.json('playfields', '/data/playfields.json'); this.load.json('playfields', '/data/playfields.json');
this.load.json('colored-playfields', '/data/colored-playfields.json');
this.load.json('card-backs', '/data/card-backs.json'); this.load.json('card-backs', '/data/card-backs.json');
this.load.json('music', '/data/music.json'); this.load.json('music', '/data/music.json');
this.load.json('rushhour', '/data/rushhour.json'); this.load.json('rushhour', '/data/rushhour.json');