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:
parent
09d94a6ae7
commit
6440328f2c
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue