feat(civilization): add coinage/public works special builds + fix AI fallback

- Add Coinage (1 shield → 1 gold) and Public Works (2 shields → 1 food)
  special build options for cities, with remainder carry logic in shieldBox
- Update AI to fall back to producing gold or food instead of stockpiling
  redundant defenders when nothing else is available
- Fix mid-build detection to check build.type before comparing shield progress
- Add City Screen UI for special builds: display gain per turn, color-coded
  rows, and tooltips
- Only apply class-switch penalty when switching between unit/building
  production types
- Add verification tests for coinage payout, public works remainder carry,
  and AI special build fallback

feat(superkart): fix player kart scale + add missing backdrop images

- Compute PLAYER_KART_SCALE from MODE7 parameters instead of hardcoding,
  keeping player and AI karts consistent at the camera's follow distance
- Wire up previously null backdrops: volcano, scrapyard, swamp, speedway,
  nightcity
This commit is contained in:
Brian Fertig 2026-07-18 09:08:26 -06:00
parent 65cc0a2c79
commit 30629380b4
12 changed files with 155 additions and 39 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

View File

@ -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 }
}

View File

@ -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) {

View File

@ -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);
});

View File

@ -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.

View File

@ -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

View File

@ -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);

View File

@ -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');
}
}
// ---------------------------------------------------------------------------