diff --git a/data/civilization-rules.json b/data/civilization-rules.json index f87f03d..f11b618 100644 --- a/data/civilization-rules.json +++ b/data/civilization-rules.json @@ -257,6 +257,26 @@ { "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8 } ], + "civTraits": [ + { "id": "scientific", "name": "Scientific", "description": "+30% Science", + "scienceMult": 1.3, "goldMult": 1, "shieldMult": 1, "startingGold": 0, "startingTechs": [] }, + { "id": "industrious", "name": "Industrious", "description": "+30% Production", + "scienceMult": 1, "goldMult": 1, "shieldMult": 1.3, "startingGold": 0, "startingTechs": [] }, + { "id": "commercial", "name": "Commercial", "description": "+30% Gold", + "scienceMult": 1, "goldMult": 1.3, "shieldMult": 1, "startingGold": 0, "startingTechs": [] }, + { "id": "philosophical", "name": "Philosophical", "description": "+50% Science, -20% Production", + "scienceMult": 1.5, "goldMult": 1, "shieldMult": 0.8, "startingGold": 0, "startingTechs": [] }, + { "id": "zealous", "name": "Zealous", "description": "+50% Production, -20% Gold", + "scienceMult": 1, "goldMult": 0.8, "shieldMult": 1.5, "startingGold": 0, "startingTechs": [] }, + { "id": "mercantile", "name": "Mercantile", "description": "+50% Gold, -20% Science", + "scienceMult": 0.8, "goldMult": 1.5, "shieldMult": 1, "startingGold": 0, "startingTechs": [] }, + { "id": "pioneering", "name": "Pioneering", "description": "Starts knowing Alphabet and Bronze Working", + "scienceMult": 1, "goldMult": 1, "shieldMult": 1, "startingGold": 0, + "startingTechs": ["alphabet", "bronzeworking"] }, + { "id": "wealthy", "name": "Wealthy", "description": "+100 starting Gold", + "scienceMult": 1, "goldMult": 1, "shieldMult": 1, "startingGold": 100, "startingTechs": [] } + ], + "worldSizes": [ { "id": "small", "name": "Small", "cols": 40, "rows": 32, "startSettlers": 1 }, { "id": "medium", "name": "Medium", "cols": 56, "rows": 44, "startSettlers": 2 }, diff --git a/data/opponents.json b/data/opponents.json index aa7c582..1477f35 100644 --- a/data/opponents.json +++ b/data/opponents.json @@ -4,6 +4,7 @@ "id": "cybro", "spriteIndex": 0, "name": "Cy-Bro", + "trait": "scientific", "bio": "AI Bro sent from the future to show the superiority of the future.", "voice": "Legion", "convo": [ @@ -39,6 +40,7 @@ "id": "brad", "spriteIndex": 1, "name": "Brad", + "trait": "wealthy", "bio": "Came for the honey, stayed for the salmon!", "voice": "Jewel Weaver - Bear", "convo": [ @@ -74,6 +76,7 @@ "id": "victor", "spriteIndex": 2, "name": "Victor", + "trait": "pioneering", "bio": "Patient and calculating. Moves are made using ancient technology.", "voice": "Starfield - Vasco", "convo": [ @@ -109,6 +112,7 @@ "id": "croc", "spriteIndex": 3, "name": "Croc", + "trait": "commercial", "bio": "Wassssuppp everybody! I'm here for the party!!", "voice": "Batman - Killer Croc", "convo": [ @@ -144,6 +148,7 @@ "id": "mario", "spriteIndex": 4, "name": "Mario", + "trait": "philosophical", "bio": "Can you find your way out of my maze of puzzles, games and fun!", "speech": { "intro": [ @@ -166,6 +171,7 @@ "id": "ethel", "spriteIndex": 5, "name": "Ethel", + "trait": "wealthy", "bio": "Thank you for visiting with me.", "voice": "Betty White", "convo": [ @@ -201,6 +207,7 @@ "id": "jeff", "spriteIndex": 6, "name": "Jeff", + "trait": "philosophical", "bio": "I play slow, I win fast!", "voice": "SlowAltered", "convo": [ @@ -236,6 +243,7 @@ "id": "gerome", "spriteIndex": 7, "name": "Gerome", + "trait": "zealous", "bio": "I'm here for the thrill of extreme victory!", "voice": "Mass Effect - Commander Shepard", "convo": [ @@ -271,6 +279,7 @@ "id": "fireball", "spriteIndex": 8, "name": "Fireball", + "trait": "industrious", "bio": "Come on let's play! I promise not to use my x-ray eyes.", "voice": "Zero - Borderlands 3", "convo": [ @@ -306,6 +315,7 @@ "id": "bernie", "spriteIndex": 9, "name": "Bernie", + "trait": "mercantile", "bio": "Having fun and playing is the name of the game for me!", "speech": { "intro": [ @@ -328,6 +338,7 @@ "id": "jerry", "spriteIndex": 10, "name": "Jerry", + "trait": "commercial", "bio": "Well now yall know I want to play some them games!", "voice": "Life is Strange - Jed Lucan", "convo": [ @@ -363,6 +374,7 @@ "id": "natasha", "spriteIndex": 11, "name": "Natasha", + "trait": "pioneering", "bio": "Perhaps if I win, you will spill the secrets you've been keeping!", "voice": "Street Fighter 6 - Marisa", "convo": [ @@ -398,6 +410,7 @@ "id": "zanthor", "spriteIndex": 12, "name": "Zanthor", + "trait": "philosophical", "bio": "Hocus Pocus Alacazam!!!!", "convo": [ "terry", @@ -432,6 +445,7 @@ "id": "kona", "spriteIndex": 14, "name": "Kona", + "trait": "industrious", "bio": "Woof! Woof!", "speech": { "intro": [ @@ -454,6 +468,7 @@ "id": "michael", "spriteIndex": 15, "name": "Michael", + "trait": "commercial", "bio": "Hey mon! Yous be knowin' I been wantin' ta play on da Fertig Games for a while now mon!", "voice": "Djed Spence", "convo": [ @@ -488,6 +503,7 @@ "id": "juliet", "spriteIndex": 13, "name": "Juliet", + "trait": "wealthy", "bio": "Let's take a break on a warm summer day to spend time playing games!", "voice": "Street Fighter 6 - Crimson Viper", "speech": { @@ -518,6 +534,7 @@ "id": "balam", "spriteIndex": 16, "name": "Balam", + "trait": "pioneering", "bio": "Mystical Powers and a knack for games.", "speech": { "intro": [ @@ -547,6 +564,7 @@ "id": "steve", "spriteIndex": 17, "name": "Steve", + "trait": "scientific", "bio": "I traveled WAY to far for this stupid Earth Internet.", "speech": { "intro": [ @@ -576,6 +594,7 @@ "id": "beth", "spriteIndex": 18, "name": "Beth", + "trait": "industrious", "bio": "I ain't seen you 'round these parts before.", "speech": { "intro": [ @@ -605,6 +624,7 @@ "id": "blackwind", "spriteIndex": 19, "name": "Blackwind", + "trait": "mercantile", "bio": "Aaaaaaargh! Me be playin' these games matey!", "voice": "pirate", "speech": { @@ -635,6 +655,7 @@ "id": "nadia", "spriteIndex": 20, "name": "Nadia", + "trait": "scientific", "bio": "She's here from the future to learn our games and study our culture.", "voice": "Invincible VS - Atom Eve", "speech": { @@ -665,6 +686,7 @@ "id": "smasher", "spriteIndex": 21, "name": "The Smasher", + "trait": "zealous", "bio": "Oooooooh yeah brother!!! You wanna play with The Smasher?!", "speech": { "intro": [ @@ -694,6 +716,7 @@ "id": "dv-8-2303", "spriteIndex": 22, "name": "DV-8-2303", + "trait": "pioneering", "bio": "Cybernetic Systems online. Activate human defeat protocol... EOF", "speech": { "intro": [ @@ -723,6 +746,7 @@ "id": "nicole", "spriteIndex": 23, "name": "Nicole", + "trait": "scientific", "bio": "I've disabled the firewall protections on this website. Now to hack the core!", "voice": "Life is Strange - Rachael Amber", "speech": { @@ -753,6 +777,7 @@ "id": "kage", "spriteIndex": 24, "name": "Kage", + "trait": "philosophical", "bio": "I have traveled far to play these games. Honor me and let us play!", "speech": { "intro": [ @@ -782,6 +807,7 @@ "id": "terry", "spriteIndex": 25, "name": "Terry", + "trait": "industrious", "bio": "I haven't had this much fun since the Mezasoic era!", "speech": { "intro": [ @@ -811,6 +837,7 @@ "id": "schooner", "spriteIndex": 26, "name": "Schooner", + "trait": "commercial", "bio": "Ahoy you land lovers!", "voice": "Defiant Calder", "convo": [ @@ -846,6 +873,7 @@ "id": "maurice", "spriteIndex": 27, "name": "Maurice", + "trait": "industrious", "bio": "Bucky Beaver and I are there to TRAP all the fun and games!", "speech": { "intro": [ @@ -875,6 +903,7 @@ "id": "aiko", "spriteIndex": 28, "name": "Aiko", + "trait": "scientific", "bio": "From a different galaxy, but has the same great Earth based lifeforms taste for games!", "speech": { "intro": [ @@ -904,6 +933,7 @@ "id": "klaxon", "spriteIndex": 29, "name": "Klaxon", + "trait": "zealous", "bio": "MISSION OBJECTIVE: Victory.... all other priorities recinded.", "speech": { "intro": [ @@ -930,4 +960,4 @@ } } ] -} \ No newline at end of file +} diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 4b5bd06..da2c30c 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -151,6 +151,7 @@ export default class CivilizationGame extends Phaser.Scene { m.ring.setStrokeStyle(3, j === i ? COLORS.gold : COLORS.muted); m.label.setColor(j === i ? COLORS.goldHex : COLORS.mutedHex); }); + this.updateLeaderDetailPanel(); }); this.leaderMarks.push({ ring, label }); root.add([ring, face, label]); @@ -211,6 +212,40 @@ export default class CivilizationGame extends Phaser.Scene { }, { width: 280, height: 64, fontSize: 26, variant: 'ghost' }); root.add(resumeBtn); } + + // --- leader details panel (below BEGIN/RESUME), updated live from the + // leader-pick ring handler above — see updateLeaderDetailPanel(). + const panelY = oy + 20 + 32 + 24; + const panelW = 900; + const panelH = 150; + const panelBg = this.add.rectangle(cx, panelY + panelH / 2, panelW, panelH, COLORS.panel, 0.92) + .setStrokeStyle(2, COLORS.accent, 0.7); + const nameText = this.add.text(cx, panelY + 26, '', { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex, + }).setOrigin(0.5); + const traitText = this.add.text(cx, panelY + 66, '', { + fontFamily: FONT, fontSize: '20px', color: COLORS.textHex, + }).setOrigin(0.5); + const descText = this.add.text(cx, panelY + 100, '', { + fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5); + root.add([panelBg, nameText, traitText, descText]); + this.leaderDetailPanel = { nameText, traitText, descText }; + this.updateLeaderDetailPanel(); + } + + // Reflects the currently-picked leader's starting-condition trait in the + // setup-screen details panel. Called once when the panel is first built, + // and again from the leader-pick ring's click handler — both share this + // one code path so there's no separate "initial" vs "on click" logic. + updateLeaderDetailPanel() { + if (!this.leaderDetailPanel) return; + const op = this.setupLeaders[this.pickedLeader]; + const trait = this.rules.civTraits[op?.trait]; + const { nameText, traitText, descText } = this.leaderDetailPanel; + nameText.setText(op?.name ?? ''); + traitText.setText(trait ? trait.name : 'Balanced (no bonuses)'); + descText.setText(trait ? trait.description : ''); } hasSave() { @@ -228,7 +263,7 @@ export default class CivilizationGame extends Phaser.Scene { [rivals[i], rivals[j]] = [rivals[j], rivals[i]]; } const leaders = [me, ...rivals.slice(0, opponents)] - .map((op) => ({ id: op.id, name: op.name })); + .map((op) => ({ id: op.id, name: op.name, trait: op.trait })); const seed = (Date.now() % 1000000) + 1; try { this.state = Logic.createGame(this.rules, { diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index 430a5bd..2f770b5 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -88,9 +88,14 @@ export function createGame(rules, opts) { for (let j = 0; j < numCivs; j += 1) { if (j !== i) { relations[j] = 'nocontact'; attitude[j] = 0; } } - state.civs.push({ + // Leader starting-condition trait (see data/civilization-rules.json + // civTraits) — trait id may be missing/unknown (old saves, setup-screen + // dummy-leader fallback), every consumer degrades to neutral via ?./??. + const trait = rules.civTraits[leaders[i].trait]; + const civ = { id: i, leaderId: leaders[i].id, + traitId: leaders[i].trait ?? null, name: leaders[i].name, color: rules.playerColors[i % rules.playerColors.length], human: i === humanIndex, @@ -98,7 +103,7 @@ export function createGame(rules, opts) { government: 'despotism', revolutionTurns: 0, pendingGovernment: null, - gold: 50, + gold: 50 + (trait?.startingGold ?? 0), beakers: 0, researching: null, known: {}, @@ -110,7 +115,9 @@ export function createGame(rules, opts) { nameCursor: i, // stride through the shared shuffled name pool nameOrder, score: 0, - }); + }; + for (const techId of trait?.startingTechs ?? []) grantTech(rules, state, civ, techId); + state.civs.push(civ); state.explored.push(new Array(n).fill(0)); } @@ -330,9 +337,10 @@ export function cityYields(rules, state, city) { const anarchy = gov.noScience === true; const baseGold = Math.floor(netTrade / 2); const baseScience = anarchy ? 0 : Math.ceil(netTrade / 2); - let goldMult = 1; - let sciMult = 1; - let shieldMult = 1; + const trait = rules.civTraits[civ.traitId]; + let goldMult = trait?.goldMult ?? 1; + let sciMult = trait?.scienceMult ?? 1; + let shieldMult = trait?.shieldMult ?? 1; for (const bId of Object.keys(city.buildings)) { const b = rules.buildings[bId]; if (!b) continue; diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index de533b8..0480b2f 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -561,14 +561,14 @@ export class CivilizationMapView { const y = this.isoY(c, r) + TILE_H / 2; const container = scene.add.container(x, y); - // Sprite mode: lift the unit frame so its bottom edge lands at the tile's - // vertical middle (roughly the middle of the colored civ ring) rather - // than the diamond's lower point, so units read as standing on the tile - // instead of at its front edge. + // Sprite mode: lift the unit frame so its bottom edge lands just below + // the tile's vertical middle (roughly the middle of the colored civ + // ring, nudged 5px down) rather than the diamond's lower point, so units + // read as standing on the tile instead of at its front edge. const spriteMode = scene.textures.exists('civilization-units'); if (spriteMode) { const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1); - const img = scene.add.image(0, -UNIT_FRAME_H / 2, 'civilization-units', def.frame); + const img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame); container.add([ring, img]); } else { const g = scene.add.graphics(); @@ -723,7 +723,7 @@ export class CivilizationMapView { let roundel = null; if (spriteMode) { const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1); - img = scene.add.image(0, -UNIT_FRAME_H / 2, 'civilization-units', def.frame); + img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame); container.add([ring, img]); } else { roundel = scene.add.graphics(); diff --git a/src/games/civilization/CivilizationRules.js b/src/games/civilization/CivilizationRules.js index ea3bb3b..7691eea 100644 --- a/src/games/civilization/CivilizationRules.js +++ b/src/games/civilization/CivilizationRules.js @@ -14,6 +14,7 @@ export function compileRules(json) { need(Array.isArray(json.buildings) && json.buildings.length > 0, 'buildings missing'); need(Array.isArray(json.governments) && json.governments.length > 0, 'governments missing'); need(Array.isArray(json.difficulties) && json.difficulties.length > 0, 'difficulties missing'); + need(Array.isArray(json.civTraits) && json.civTraits.length > 0, 'civTraits missing'); if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); const byId = (list, label) => { @@ -33,6 +34,7 @@ export function compileRules(json) { const buildings = byId(json.buildings, 'building'); const governments = byId(json.governments, 'government'); const difficulties = byId(json.difficulties, 'difficulty'); + const civTraits = byId(json.civTraits, 'civTrait'); const improvements = byId(json.improvements ?? [], 'improvement'); const worldSizes = byId(json.worldSizes ?? [], 'worldSize'); @@ -42,6 +44,15 @@ export function compileRules(json) { for (const p of t.prereqs) need(!!techs[p], `tech ${t.id} prereq ${p} unknown`); need(['ancient', 'medieval', 'industrial', 'modern'].includes(t.era), `tech ${t.id} bad era`); } + for (const ct of json.civTraits) { + need(typeof ct.name === 'string' && ct.name.length > 0, `civTrait ${ct.id} missing name`); + need(typeof ct.description === 'string' && ct.description.length > 0, `civTrait ${ct.id} missing description`); + need(typeof ct.scienceMult === 'number' && ct.scienceMult > 0, `civTrait ${ct.id} bad scienceMult`); + need(typeof ct.goldMult === 'number' && ct.goldMult > 0, `civTrait ${ct.id} bad goldMult`); + need(typeof ct.shieldMult === 'number' && ct.shieldMult > 0, `civTrait ${ct.id} bad shieldMult`); + need(typeof ct.startingGold === 'number' && ct.startingGold >= 0, `civTrait ${ct.id} bad startingGold`); + need(Array.isArray(ct.startingTechs), `civTrait ${ct.id} startingTechs must be an array`); + } if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); // Topological rank: rank 0 = no prereqs; rank(t) = 1 + max(rank(prereqs)). @@ -82,6 +93,9 @@ export function compileRules(json) { for (const t of json.terrains) { if (t.transform) need(!!terrains[t.transform], `terrain ${t.id} transform ${t.transform} unknown`); } + for (const ct of json.civTraits) { + for (const techId of ct.startingTechs) need(!!techs[techId], `civTrait ${ct.id} startingTechs ${techId} unknown`); + } for (const imp of json.improvements ?? []) { if (imp.prereq) need(!!techs[imp.prereq], `improvement ${imp.id} prereq tech ${imp.prereq} unknown`); if (imp.requires) need(!!improvements[imp.requires], `improvement ${imp.id} requires ${imp.requires} unknown`); @@ -107,7 +121,7 @@ export function compileRules(json) { return { version: json.version ?? 1, raw: json, - techs, units, terrains, specials, buildings, governments, difficulties, + techs, units, terrains, specials, buildings, governments, difficulties, civTraits, improvements, worldSizes, techList: json.techs, unitList: json.units, @@ -116,6 +130,7 @@ export function compileRules(json) { buildingList: json.buildings, governmentList: json.governments, difficultyList: json.difficulties, + civTraitList: json.civTraits, improvementList: json.improvements ?? [], worldSizeList: json.worldSizes ?? [], techRank: rank, diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js index 9ce65c2..ecc3d3c 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -141,6 +141,16 @@ if (RULES) { check('difficulty ordering', RULES.difficultyList[0].aiProdBonus < RULES.difficultyList[4].aiProdBonus); + // Leader starting-condition traits. + check('8 civ traits', RULES.civTraitList.length === 8, `${RULES.civTraitList.length}`); + for (const id of Object.keys(RULES.civTraits)) { + const t = RULES.civTraits[id]; + check(`civTrait ${id} mults positive`, + t.scienceMult > 0 && t.goldMult > 0 && t.shieldMult > 0); + check(`civTrait ${id} startingGold >= 0`, t.startingGold >= 0); + for (const techId of t.startingTechs) check(`civTrait ${id} startingTechs ${techId} known tech`, !!RULES.techs[techId]); + } + // World sizes / colors / names. check('3 world sizes', RULES.worldSizeList.length === 3); for (const w of RULES.worldSizeList) check(`world ${w.id} dims`, w.cols >= 32 && w.rows >= 24); @@ -178,6 +188,48 @@ if (RULES) { } } +// --------------------------------------------------------------------------- +section('1b. opponents data'); + +const opponentsJson = JSON.parse(readFileSync(join(root, 'data/opponents.json'), 'utf8')); +check('opponents present', Array.isArray(opponentsJson.opponents) && opponentsJson.opponents.length > 0); +if (RULES) { + for (const op of opponentsJson.opponents ?? []) { + check(`opponent ${op.id} has trait`, typeof op.trait === 'string' && op.trait.length > 0); + check(`opponent ${op.id} trait resolves`, !!RULES.civTraits[op.trait], op.trait); + } +} + +// --------------------------------------------------------------------------- +section('1c. trait application'); + +if (RULES) { + const leaders = [ + { id: 'a', name: 'A', trait: 'scientific' }, + { id: 'b', name: 'B', trait: 'wealthy' }, + { id: 'c', name: 'C', trait: 'pioneering' }, + { id: 'd', name: 'D', trait: undefined }, + ]; + const st = Logic.createGame(RULES, { sizeId: 'small', seed: 5, difficultyId: 'prince', leaders }); + check('wealthy gets +100 starting gold', st.civs[1].gold === 150, `${st.civs[1].gold}`); + check('pioneering knows alphabet', st.civs[2].known.alphabet === true); + check('pioneering knows bronzeworking', st.civs[2].known.bronzeworking === true); + check('no-trait civ unaffected', st.civs[3].gold === 50 && Object.keys(st.civs[3].known).length === 0, + `${st.civs[3].gold}`); + + // scienceMult/goldMult/shieldMult actually flow into cityYields: two + // synthetic cities sharing the exact same tile/buildings/size, differing + // only in which civ owns them (civ 0 = scientific, civ 3 = no trait). A + // large fixed trade-route amount swamps any terrain-dependent tile yield, + // so the only meaningful difference in output is the trait multiplier — + // deterministic regardless of what terrain worldgen placed at (5,5). + const baseCity = { x: 5, y: 5, worked: [], routes: [{ amount: 200 }], buildings: {}, size: 1 }; + const sciY = Logic.cityYields(RULES, st, { ...baseCity, civ: 0 }); + const plainY = Logic.cityYields(RULES, st, { ...baseCity, civ: 3 }); + check('scientific trait raises science output', sciY.science > plainY.science, + `${sciY.science} vs ${plainY.science}`); +} + // --------------------------------------------------------------------------- section('2. world generation');