diff --git a/assets/images/superkart/backdrop-nightcity.png b/assets/images/superkart/backdrop-nightcity.png new file mode 100644 index 0000000..21153ed Binary files /dev/null and b/assets/images/superkart/backdrop-nightcity.png differ diff --git a/assets/images/superkart/backdrop-scrapyard.png b/assets/images/superkart/backdrop-scrapyard.png new file mode 100644 index 0000000..e807ed2 Binary files /dev/null and b/assets/images/superkart/backdrop-scrapyard.png differ diff --git a/assets/images/superkart/backdrop-speedway.png b/assets/images/superkart/backdrop-speedway.png new file mode 100644 index 0000000..0267ca9 Binary files /dev/null and b/assets/images/superkart/backdrop-speedway.png differ diff --git a/assets/images/superkart/backdrop-swamp.png b/assets/images/superkart/backdrop-swamp.png new file mode 100644 index 0000000..4a22f2f Binary files /dev/null and b/assets/images/superkart/backdrop-swamp.png differ diff --git a/assets/images/superkart/backdrop-volcano.png b/assets/images/superkart/backdrop-volcano.png new file mode 100644 index 0000000..fa7fd66 Binary files /dev/null and b/assets/images/superkart/backdrop-volcano.png differ diff --git a/data/superkart-artwork.json b/data/superkart-artwork.json index a84ee62..6e45e38 100644 --- a/data/superkart-artwork.json +++ b/data/superkart-artwork.json @@ -50,11 +50,11 @@ }, "backdrops": { "beach": { "key": "superkart-backdrop-beach", "path": "assets/images/superkart/backdrop-beach.png" }, - "volcano": { "key": "superkart-backdrop-volcano", "path": null }, - "scrapyard": { "key": "superkart-backdrop-scrapyard", "path": null }, - "swamp": { "key": "superkart-backdrop-swamp", "path": null }, - "speedway": { "key": "superkart-backdrop-speedway", "path": null }, - "nightcity": { "key": "superkart-backdrop-nightcity", "path": null } + "volcano": { "key": "superkart-backdrop-volcano", "path": "assets/images/superkart/backdrop-volcano.png" }, + "scrapyard": { "key": "superkart-backdrop-scrapyard", "path": "assets/images/superkart/backdrop-scrapyard.png" }, + "swamp": { "key": "superkart-backdrop-swamp", "path": "assets/images/superkart/backdrop-swamp.png" }, + "speedway": { "key": "superkart-backdrop-speedway", "path": "assets/images/superkart/backdrop-speedway.png" }, + "nightcity": { "key": "superkart-backdrop-nightcity", "path": "assets/images/superkart/backdrop-nightcity.png" } }, "itemSheet": { "key": "superkart-items", "path": null, "frameWidth": 48, "frameHeight": 48 } } diff --git a/src/games/civilization/CivilizationAI.js b/src/games/civilization/CivilizationAI.js index 8839e8c..8f78284 100644 --- a/src/games/civilization/CivilizationAI.js +++ b/src/games/civilization/CivilizationAI.js @@ -242,7 +242,8 @@ const DEVELOP_CHAIN = ['granary', 'library', 'marketplace', 'barracks', 'aqueduc function manageCityBuild(rules, state, civIdx, city, strategy) { const civ = state.civs[civIdx]; const def = rules.units[city.build?.id]; - const midBuild = city.shieldBox > 0 && city.shieldBox < buildCost(rules, city) * 0.9; + const midBuild = (city.build.type === 'unit' || city.build.type === 'building') + && city.shieldBox > 0 && city.shieldBox < buildCost(rules, city) * 0.9; const defenders = unitsAt(state, city.x, city.y) .filter((u) => u.civ === civIdx && rules.units[u.type].domain === 'land' && !rules.units[u.type].flags.includes('noncombat')); @@ -335,8 +336,10 @@ function manageCityBuild(rules, state, civIdx, city, strategy) { return; } } - const fallback = bestDefender(rules, state, civ, city); - if (fallback) setBuild(rules, state, city, 'unit', fallback.id); + // Nothing left to build or develop — convert shields to gold or food + // instead of stockpiling redundant defenders. + const preferFood = yields.foodSurplus < 3; + setBuild(rules, state, city, preferFood ? 'food' : 'gold', preferFood ? 'publicworks' : 'coinage'); } function bestDefender(rules, state, civ, city) { diff --git a/src/games/civilization/CivilizationCityScreen.js b/src/games/civilization/CivilizationCityScreen.js index a4ae905..a41ad99 100644 --- a/src/games/civilization/CivilizationCityScreen.js +++ b/src/games/civilization/CivilizationCityScreen.js @@ -5,7 +5,7 @@ import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { Tooltip } from '../../ui/Tooltip.js'; import * as Logic from './CivilizationLogic.js'; -import { describeUnitTooltip, describeBuildingTooltip } from './CivilizationTooltips.js'; +import { describeUnitTooltip, describeBuildingTooltip, describeSpecialBuildTooltip } from './CivilizationTooltips.js'; const FONT = '"Julius Sans One"'; @@ -117,29 +117,42 @@ export function openCityScreen(scene, rules, state, city, onClose) { // --- Build picker (right column) const rx = px + 60; - const cost = Logic.buildCost(rules, city); - const cur = city.build.type === 'unit' ? rules.units[city.build.id] : rules.buildings[city.build.id]; - const shownShields = Math.min(city.shieldBox, cost); - const turns = y.shield > 0 ? Math.max(0, Math.ceil((cost - shownShields) / y.shield)) : '∞'; - dynamic.add(scene.add.text(rx, top + 64, - `Building: ${cur.name} ${shownShields}/${cost} shields (${turns} turns)`, { - fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, - })); - const buyPrice = Logic.buyCost(rules, city); - const buyBtn = new Button(scene, rx + 560, top + 120, `BUY (${buyPrice}g)`, () => { - if (Logic.buyBuild(rules, state, city)) redraw(); - }, { width: 190, height: 46, fontSize: 18, variant: civ.gold >= buyPrice && !city.boughtThisTurn ? 'solid' : 'ghost' }); - dynamic.add(buyBtn); + const isSpecialBuild = city.build.type === 'gold' || city.build.type === 'food'; + if (isSpecialBuild) { + const spec = Logic.SPECIAL_BUILDS[city.build.type]; + const gain = Math.floor((city.shieldBox + y.shield) / spec.den) * spec.num; + const unitLabel = city.build.type === 'gold' ? 'gold' : 'food'; + dynamic.add(scene.add.text(rx, top + 64, + `Producing: ${spec.name} +${gain} ${unitLabel}/turn`, { + fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, + })); + } else { + const cost = Logic.buildCost(rules, city); + const cur = city.build.type === 'unit' ? rules.units[city.build.id] : rules.buildings[city.build.id]; + const shownShields = Math.min(city.shieldBox, cost); + const turns = y.shield > 0 ? Math.max(0, Math.ceil((cost - shownShields) / y.shield)) : '∞'; + dynamic.add(scene.add.text(rx, top + 64, + `Building: ${cur.name} ${shownShields}/${cost} shields (${turns} turns)`, { + fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, + })); + const buyPrice = Logic.buyCost(rules, city); + const buyBtn = new Button(scene, rx + 560, top + 120, `BUY (${buyPrice}g)`, () => { + if (Logic.buyBuild(rules, state, city)) redraw(); + }, { width: 190, height: 46, fontSize: 18, variant: civ.gold >= buyPrice && !city.boughtThisTurn ? 'solid' : 'ghost' }); + dynamic.add(buyBtn); + } dynamic.add(scene.add.text(rx, top + 108, `Treasury: ${civ.gold} gold`, { fontFamily: FONT, fontSize: '19px', color: COLORS.goldHex, })); - // Choices: units then buildings, two columns of rows. + // Choices: Coinage/Public Works, then units, then buildings, two columns of rows. + const specialChoices = Object.entries(Logic.SPECIAL_BUILDS) + .map(([type, spec]) => ({ type, id: spec.id, label: spec.name })); const unitChoices = Logic.availableUnits(rules, state, civ, city) .map((u) => ({ type: 'unit', id: u.id, label: `${u.name} (${u.cost})`, ruleObj: u })); const bldChoices = Logic.availableBuildings(rules, state, civ, city) .map((b) => ({ type: 'building', id: b.id, label: `${b.name} (${b.cost})`, ruleObj: b })); - const choices = [...unitChoices, ...bldChoices]; + const choices = [...specialChoices, ...unitChoices, ...bldChoices]; const colW = 330; const rowH = 36; const perCol = 14; // leaves room below for the garrison strip @@ -150,18 +163,20 @@ export function openCityScreen(scene, rules, state, city, onClose) { const rect = scene.add.rectangle(colX + colW / 2, rowY, colW, rowH - 4, active ? 0x3a3222 : 0x181510) .setStrokeStyle(1, active ? COLORS.gold : COLORS.muted, active ? 1 : 0.5); + const rowColor = ch.type === 'unit' ? '#cfe3ff' : (ch.type === 'building' ? '#ffe9bd' : '#c9f2c0'); const txt = scene.add.text(colX + 10, rowY, ch.label, { - fontFamily: FONT, fontSize: '16px', - color: ch.type === 'unit' ? '#cfe3ff' : '#ffe9bd', + fontFamily: FONT, fontSize: '16px', color: rowColor, }).setOrigin(0, 0.5); rect.setInteractive({ useHandCursor: true }); rect.on('pointerdown', () => { Logic.setBuild(rules, state, city, ch.type, ch.id); redraw(); }); - tooltip.attachTo(rect, () => (ch.type === 'unit' - ? describeUnitTooltip(rules, ch.ruleObj) - : describeBuildingTooltip(rules, ch.ruleObj))); + tooltip.attachTo(rect, () => { + if (ch.type === 'unit') return describeUnitTooltip(rules, ch.ruleObj); + if (ch.type === 'building') return describeBuildingTooltip(rules, ch.ruleObj); + return describeSpecialBuildTooltip(ch.type); + }); dynamic.add(rect); dynamic.add(txt); }); diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index 8d71ce5..c5267cd 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -468,8 +468,22 @@ export function foundCity(rules, state, unit, name) { return city; } +// Coinage/Public Works: convert shields straight into gold/food every turn +// instead of banking them toward a unit/building. `den` lets food run at a +// worse ratio than gold without losing shields — see processCity, which +// reuses city.shieldBox as the carry register for the remainder. +export const SPECIAL_BUILDS = { + gold: { id: 'coinage', name: 'Coinage', num: 1, den: 1 }, + food: { id: 'publicworks', name: 'Public Works', num: 1, den: 2 }, +}; + +function isProductionClass(type) { + return type === 'unit' || type === 'building'; +} + export function setBuild(rules, state, city, type, id) { - if (city.build && city.build.type !== type && city.shieldBox > 0) { + if (city.build && city.build.type !== type && city.shieldBox > 0 + && isProductionClass(city.build.type) && isProductionClass(type)) { city.shieldBox = Math.floor(city.shieldBox / 2); // Civ II class-switch penalty } city.build = { type, id }; @@ -574,12 +588,24 @@ function processCity(rules, state, city) { if (city.size <= 0) { destroyCity(rules, state, city); return; } } - // Shields. Capped at cost — a settler build stalled on city.size < 2 - // (see completeBuild) would otherwise keep banking shields turn after - // turn with nowhere to go, showing e.g. "60/40 shields" and a negative - // turn count once shields overshot the cost. - city.shieldBox = Math.min(city.shieldBox + y.shield, buildCost(rules, city)); - if (city.shieldBox >= buildCost(rules, city)) completeBuild(rules, state, city); + // Shields. + if (city.build.type === 'gold' || city.build.type === 'food') { + // Coinage/Public Works: shieldBox is repurposed as a carry register so + // the 2:1 food ratio never loses an odd shield across turns. + const spec = SPECIAL_BUILDS[city.build.type]; + city.shieldBox += y.shield; + const gain = Math.floor(city.shieldBox / spec.den) * spec.num; + city.shieldBox -= Math.floor(gain / spec.num) * spec.den; + if (city.build.type === 'gold') civ.gold += gain; + else city.foodBox += gain; + } else { + // Capped at cost — a settler build stalled on city.size < 2 + // (see completeBuild) would otherwise keep banking shields turn after + // turn with nowhere to go, showing e.g. "60/40 shields" and a negative + // turn count once shields overshot the cost. + city.shieldBox = Math.min(city.shieldBox + y.shield, buildCost(rules, city)); + if (city.shieldBox >= buildCost(rules, city)) completeBuild(rules, state, city); + } city.boughtThisTurn = false; // Economy. diff --git a/src/games/civilization/CivilizationTooltips.js b/src/games/civilization/CivilizationTooltips.js index e8b6366..7da0e14 100644 --- a/src/games/civilization/CivilizationTooltips.js +++ b/src/games/civilization/CivilizationTooltips.js @@ -1,5 +1,7 @@ import { COLORS } from '../../config.js'; -import { cityById, cityAt, tileIndex, defenderStrength, unitsOnBoat, IMP } from './CivilizationLogic.js'; +import { + cityById, cityAt, tileIndex, defenderStrength, unitsOnBoat, IMP, SPECIAL_BUILDS, +} from './CivilizationLogic.js'; // Flavor/ability text for unit flags — data/civilization-rules.json has no // description field on units, so this is authored here. A few flags @@ -101,6 +103,15 @@ export function describeBuildingTooltip(rules, building) { return { title: building.name, lines }; } +// Coinage/Public Works: no cost/upkeep to report, just what the conversion does. +export function describeSpecialBuildTooltip(type) { + const spec = SPECIAL_BUILDS[type]; + const text = type === 'gold' + ? 'Converts this city\'s shield production directly into gold, 1 shield = 1 gold, every turn.' + : 'Converts this city\'s shield production directly into food, 2 shields = 1 food, every turn.'; + return { title: spec.name, lines: [{ text }] }; +} + // Combat is always a duel against the tile's single strongest defender // (pickDefender), so enemy tooltips surface that unit's effective defense — // terrain, fortify, walls etc. included — instead of letting the raw stat diff --git a/src/games/superkart/SuperKartGame.js b/src/games/superkart/SuperKartGame.js index c7b195c..5bea6b9 100644 --- a/src/games/superkart/SuperKartGame.js +++ b/src/games/superkart/SuperKartGame.js @@ -39,6 +39,11 @@ const D = { }; const ITEM_FRAME = { bolt: 0, seeker: 1, oil: 2, turbo: 3, overdrive: 4, emp: 5, coins: 6 }; const BOX_FRAME = 7; +// The player kart is a screen-fixed sprite rather than a projected world +// sprite, so its scale is pinned to what MODE7.project() would give an AI +// kart sitting at the camera's follow distance — keeps both the same size +// when a rival is drafting right alongside the player. +const PLAYER_KART_SCALE = (MODE7.spriteBase / 64) * (MODE7.focal / MODE7.followDist); const COIN_FRAME = 8; export default class SuperKartGame extends Phaser.Scene { @@ -520,7 +525,7 @@ export default class SuperKartGame extends Phaser.Scene { // Player kart, fixed at the bottom center like the SNES original. const pk = this.playerKartState(); this.playerSprite = this.add.sprite(GAME_WIDTH / 2, GAME_HEIGHT * 0.75, - this.kartTexKey(pk.racer.id), 0).setDepth(D.playerKart).setScale(3.1); + this.kartTexKey(pk.racer.id), 0).setDepth(D.playerKart).setScale(PLAYER_KART_SCALE); this.debugG = this.add.graphics().setDepth(D.debug).setVisible(this.debugOn); } @@ -721,7 +726,7 @@ export default class SuperKartGame extends Phaser.Scene { } s.setFrame(frame); s.setAngle(angle); - let scale = 3.1; + let scale = PLAYER_KART_SCALE; if (kart.empMs > 0) scale *= 0.55; if (kart.squashMs > 0) s.setScale(scale * 1.2, scale * 0.5); else s.setScale(scale); diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js index 1c0aa2e..dc690a6 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -622,6 +622,62 @@ if (RULES) { check('revolution completes', civ.government === 'monarchy'); check('cannot switch to unknown gov', !Logic.startRevolution(RULES, st, civ, 'democracy')); } + + // Coinage: shields convert straight to gold, 1:1, every turn, no shieldBox growth. + { + const st = makeFlatState(); + const settler = Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null); + const city = Logic.foundCity(RULES, st, settler); + Logic.setBuild(RULES, st, city, 'gold', 'coinage'); + const civ = st.civs[0]; + civ.gold = 0; + const y1 = Logic.cityYields(RULES, st, city); + Logic.beginCivTurn(RULES, st, 0); + const expected = y1.gold - y1.upkeep - y1.supportGold + y1.shield; + check('coinage pays exactly y.shield gold on top of normal trade income', civ.gold === expected); + check('coinage leaves shieldBox at 0', city.shieldBox === 0); + const goldAfter1 = civ.gold; + Logic.beginCivTurn(RULES, st, 0); + check('coinage keeps paying out turn after turn', civ.gold > goldAfter1); + } + + // Public Works: shields convert to food at 2:1, with the odd shield carried + // in shieldBox instead of lost, and switching build types doesn't halve it. + { + const st = makeFlatState(); + const settler = Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null); + const city = Logic.foundCity(RULES, st, settler); + Logic.setBuild(RULES, st, city, 'food', 'publicworks'); + const y = Logic.cityYields(RULES, st, city); + const foodBoxBefore = city.foodBox; + Logic.beginCivTurn(RULES, st, 0); + const gain1 = Math.floor(y.shield / 2); + const remainder1 = y.shield - gain1 * 2; // 0 or 1, carried in shieldBox + check('public works food gain matches floor(shield/2)', + city.foodBox === foodBoxBefore + y.foodSurplus + gain1); + check('odd shield is carried in shieldBox rather than lost', city.shieldBox === remainder1); + const carry = city.shieldBox; + check('switching away from a special build does not halve the carry', + (() => { Logic.setBuild(RULES, st, city, 'building', 'granary'); return city.shieldBox === carry; })()); + } + + // AI fallback: an idle city with nothing left to build picks gold or food + // instead of endlessly stacking defenders. + { + const st = makeFlatState(); + const settler = Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null); + const city = Logic.foundCity(RULES, st, settler); + st.civs[0].human = false; + // Give it a defender so the garrison branch doesn't fire, and mark every + // building/unit tier as already handled so develop/expand/war all pass. + Logic.spawnUnit(RULES, st, 0, 'phalanx', 5, 5, city.id); + for (const b of RULES.buildingList) city.buildings[b.id] = true; + delete city.buildings.palace; // keep palace semantics untouched + city.buildings.palace = true; + AI.runAITurn(RULES, st, 0); + check('idle AI city settles on Coinage or Public Works', + city.build.type === 'gold' || city.build.type === 'food'); + } } // ---------------------------------------------------------------------------