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:
parent
65cc0a2c79
commit
30629380b4
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 |
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,6 +117,16 @@ export function openCityScreen(scene, rules, state, city, onClose) {
|
|||
|
||||
// --- Build picker (right column)
|
||||
const rx = px + 60;
|
||||
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);
|
||||
|
|
@ -130,16 +140,19 @@ export function openCityScreen(scene, rules, state, city, onClose) {
|
|||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue