From 6440328f2c2f5a14b36a6aa9d32f6caeff01c3cf Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 13 Jun 2026 09:04:42 -0600 Subject: [PATCH] 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 --- public/data/colored-playfields.json | 124 +++++++++++++++++++ public/data/playfields.json | 10 +- public/src/scenes/OpponentSelectScene.js | 145 ++++++++++++++++++++++- public/src/scenes/PreloadScene.js | 1 + 4 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 public/data/colored-playfields.json diff --git a/public/data/colored-playfields.json b/public/data/colored-playfields.json new file mode 100644 index 0000000..2362fd4 --- /dev/null +++ b/public/data/colored-playfields.json @@ -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"] + } + ] +} diff --git a/public/data/playfields.json b/public/data/playfields.json index c98a661..23e7067 100644 --- a/public/data/playfields.json +++ b/public/data/playfields.json @@ -1,6 +1,12 @@ { - "default": "cherry", + "default": "colored", "playfields": [ + { + "id": "colored", + "name": "Colored", + "type": "colored", + "fallbackColor": "#1a1a3a" + }, { "id": "cherry", "name": "Cherry Wooden Table", @@ -34,7 +40,7 @@ "fallbackColor": "#14532d" }, { - "id": "felt", + "id": "blue", "name": "Blue Felt", "key": "playfield-blue", "path": "/assets/images/playfield-felt-blue.png", diff --git a/public/src/scenes/OpponentSelectScene.js b/public/src/scenes/OpponentSelectScene.js index 52a3e6e..edd1cb1 100644 --- a/public/src/scenes/OpponentSelectScene.js +++ b/public/src/scenes/OpponentSelectScene.js @@ -41,6 +41,9 @@ export default class OpponentSelectScene extends Phaser.Scene { this.selectedDifficulty = 'novice'; // Forbidden Island water-level start this._initializing = false; this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only) + this.selectedColorScheme = null; + this._colorDropdownDomEl = null; + this._coloredThumbDomEl = null; } async create() { @@ -135,8 +138,20 @@ export default class OpponentSelectScene extends Phaser.Scene { if (this.gameDef.slug === 'forbiddenisland') this.buildDifficultySection(340, 1013); 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)); + 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') { @@ -149,6 +164,9 @@ export default class OpponentSelectScene extends Phaser.Scene { if (!isWordGame && this.gameDef.slug !== 'battleship' && this.gameDef.slug !== 'mastermind') { const pfd = this.cache.json.get('playfields') ?? {}; 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') { 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) .setDisplaySize(thumbW, thumbH) .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 { const fallbackHex = item.fallbackColor ?? '#1a3a6b'; 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; } + // ── 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 ───────────────────────────────────────────────────────────── startGame() { if (this.selected.size < (this.gameDef.minOpponents ?? 1)) return; this._startingGame = true; 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 .filter(({ opp }) => this.selected.has(opp.id)) .map(({ opp }) => ({ ...opp, skill: this.skillByOpp[opp.id] ?? 3 })); diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 9dd1436..a81a58d 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -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('main-title', '/assets/images/main-title.png'); 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('music', '/data/music.json'); this.load.json('rushhour', '/data/rushhour.json');