feat(civilization): balance difficulty combat, enhance UI tooltips, and improve setup flow
- Introduce difficulty-based combat multipliers and veteran promotion chances (Chieftain/Warlord eased, Prince+ neutral) - Randomize leader selection on setup screen and add dynamic portrait with pick speech - Replace inline hover text with a centralized Tooltip component for tech tree, foreign cities, and buildings - Add descriptive flavor text for governments and terrain improvements - Add building completion notifications with a "VIEW CITY" action button - Expand deferred city attack cinematics to cover explored rival cities - Improve Tempest flipper AI to scale lane-flipping chance/cooldown with level and enable climbing flips - Add verification tests for difficulty combat modifiers and veteran rates
This commit is contained in:
parent
0f2fc829c8
commit
a91db38a0f
|
|
@ -250,11 +250,11 @@
|
|||
],
|
||||
|
||||
"difficulties": [
|
||||
{ "id": "chieftain", "name": "Chieftain", "humanResearchFactor": 0.8, "aiProdBonus": 0.8, "aiScienceBonus": 0.8, "aiStartUnits": 0, "aiAggression": 0.25 },
|
||||
{ "id": "warlord", "name": "Warlord", "humanResearchFactor": 0.9, "aiProdBonus": 0.9, "aiScienceBonus": 0.9, "aiStartUnits": 0, "aiAggression": 0.4 },
|
||||
{ "id": "prince", "name": "Prince", "humanResearchFactor": 1.0, "aiProdBonus": 1.0, "aiScienceBonus": 1.0, "aiStartUnits": 0, "aiAggression": 0.5 },
|
||||
{ "id": "king", "name": "King", "humanResearchFactor": 1.1, "aiProdBonus": 1.25, "aiScienceBonus": 1.25, "aiStartUnits": 1, "aiAggression": 0.65 },
|
||||
{ "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8 }
|
||||
{ "id": "chieftain", "name": "Chieftain", "humanResearchFactor": 0.8, "aiProdBonus": 0.8, "aiScienceBonus": 0.8, "aiStartUnits": 0, "aiAggression": 0.25, "humanCombatBonus": 1.25, "aiCombatBonus": 0.8, "humanVetChance": 0.75 },
|
||||
{ "id": "warlord", "name": "Warlord", "humanResearchFactor": 0.9, "aiProdBonus": 0.9, "aiScienceBonus": 0.9, "aiStartUnits": 0, "aiAggression": 0.4, "humanCombatBonus": 1.1, "aiCombatBonus": 0.9, "humanVetChance": 0.65 },
|
||||
{ "id": "prince", "name": "Prince", "humanResearchFactor": 1.0, "aiProdBonus": 1.0, "aiScienceBonus": 1.0, "aiStartUnits": 0, "aiAggression": 0.5, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5 },
|
||||
{ "id": "king", "name": "King", "humanResearchFactor": 1.1, "aiProdBonus": 1.25, "aiScienceBonus": 1.25, "aiStartUnits": 1, "aiAggression": 0.65, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5 },
|
||||
{ "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8, "humanCombatBonus": 1.0, "aiCombatBonus": 1.0, "humanVetChance": 0.5 }
|
||||
],
|
||||
|
||||
"civTraits": [
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
|||
import { Button } from '../../ui/Button.js';
|
||||
import { TextInput } from '../../ui/TextInput.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { createOpponentPortrait } from '../../ui/Portrait.js';
|
||||
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
|
||||
import { compileRules, turnToYear, formatYear } from './CivilizationRules.js';
|
||||
import * as Logic from './CivilizationLogic.js';
|
||||
import { runAITurn, respondToProposal } from './CivilizationAI.js';
|
||||
|
|
@ -141,7 +143,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
const leaders = this.opponentsData.length ? this.opponentsData
|
||||
: Array.from({ length: 8 }, (_, i) => ({ id: `leader${i}`, name: `Leader ${i + 1}`, spriteIndex: 0 }));
|
||||
this.setupLeaders = leaders;
|
||||
this.pickedLeader = this.pickedLeader ?? 0;
|
||||
this.pickedLeader = this.pickedLeader ?? Math.floor(Math.random() * leaders.length);
|
||||
|
||||
const perRow = 15;
|
||||
const cell = 96;
|
||||
|
|
@ -172,12 +174,14 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5);
|
||||
ring.setInteractive({ useHandCursor: true });
|
||||
ring.on('pointerdown', () => {
|
||||
if (i === this.pickedLeader) return;
|
||||
this.pickedLeader = i;
|
||||
this.leaderMarks.forEach((m, j) => {
|
||||
m.ring.setStrokeStyle(3, j === i ? COLORS.gold : COLORS.muted);
|
||||
m.label.setColor(j === i ? COLORS.goldHex : COLORS.mutedHex);
|
||||
});
|
||||
this.updateLeaderDetailPanel();
|
||||
this.updateSetupPortrait({ playPick: true });
|
||||
});
|
||||
this.leaderMarks.push({ ring, label });
|
||||
root.add([ring, face, label]);
|
||||
|
|
@ -258,6 +262,12 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
root.add([panelBg, nameText, traitText, descText]);
|
||||
this.leaderDetailPanel = { nameText, traitText, descText };
|
||||
this.updateLeaderDetailPanel();
|
||||
|
||||
// Video portrait of the currently-picked leader, in the empty margin
|
||||
// left of the details panel — plays their idle loop; see
|
||||
// updateSetupPortrait() for the pick-speech swap on selection change.
|
||||
this.setupPortraitPos = { x: cx - panelW / 2 - 110, y: panelY + panelH / 2, radius: 85 };
|
||||
this.updateSetupPortrait({ playPick: false });
|
||||
}
|
||||
|
||||
// Reflects the currently-picked leader's starting-condition trait in the
|
||||
|
|
@ -278,6 +288,24 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
descText.setText(lines.join(' • '));
|
||||
}
|
||||
|
||||
// (Re)builds the setup screen's leader portrait for whoever is currently
|
||||
// picked — createOpponentPortrait autoplays that leader's idle video
|
||||
// immediately. When playPick is true (an actual selection change, not the
|
||||
// initial random pick), also fires their one-line "pick" speech clip —
|
||||
// deterministically, not through Portrait.js's playEmotion() 60%-chance
|
||||
// gate (that gate exists to stop repeated in-match mood banter from
|
||||
// getting spammy; a deliberate click should always be acknowledged).
|
||||
updateSetupPortrait({ playPick }) {
|
||||
this.setupPortrait?.destroy();
|
||||
const op = this.setupLeaders[this.pickedLeader];
|
||||
const { x, y, radius } = this.setupPortraitPos;
|
||||
this.setupPortrait = createOpponentPortrait(this, op, x, y, radius, 11, { playIntro: false });
|
||||
if (playPick) {
|
||||
const clip = op?.speech?.pick?.[0];
|
||||
if (clip) enqueueSpeech(clip);
|
||||
}
|
||||
}
|
||||
|
||||
hasSave() {
|
||||
try { return !!localStorage.getItem(SAVE_KEY); } catch (_) { return false; }
|
||||
}
|
||||
|
|
@ -334,6 +362,8 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
this.phase = 'playing';
|
||||
this.setupRoot?.destroy(true);
|
||||
this.setupRoot = null;
|
||||
this.setupPortrait?.destroy();
|
||||
this.setupPortrait = null;
|
||||
this.view = new CivilizationMapView(this, this.rules, this.state, this.opponentsData, {
|
||||
onCityClick: (city) => this.onCityClick(city),
|
||||
onUnitClick: (unit) => this.onUnitClick(unit),
|
||||
|
|
@ -466,8 +496,16 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
// `proceed` callback to call once it's done. Used for city-attack/capture
|
||||
// announcements, which should show the attack playing out before the
|
||||
// outcome text, rather than the text popping up first.
|
||||
announceStatus(msg, onDismiss, pre) {
|
||||
this.statusQueue.push({ msg, onDismiss, pre });
|
||||
//
|
||||
// `extra`, if given, is a second button `{ label, onClick }` shown next to
|
||||
// CONTINUE — dismisses the popup the same way, then calls `onClick`
|
||||
// instead of advancing the queue (e.g. building-complete opens the city
|
||||
// screen; that screen's own onClose is responsible for resuming the queue
|
||||
// once it closes — see the buildingDone handler in announceEvents()).
|
||||
announceStatus(msg, onDismiss, pre, extra) {
|
||||
this.statusQueue.push({
|
||||
msg, onDismiss, pre, extra,
|
||||
});
|
||||
if (!this.modalOpen) this.showNextStatus();
|
||||
}
|
||||
|
||||
|
|
@ -478,12 +516,14 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
// `proceed()` runs — a `pre` cinematic (camera pan + combat replay)
|
||||
// should block input exactly like the popup that follows it does.
|
||||
this.modalOpen = true;
|
||||
const { msg, onDismiss, pre } = item;
|
||||
const proceed = () => this.showStatusPopup(msg, onDismiss);
|
||||
const {
|
||||
msg, onDismiss, pre, extra,
|
||||
} = item;
|
||||
const proceed = () => this.showStatusPopup(msg, onDismiss, extra);
|
||||
if (pre) pre(proceed); else proceed();
|
||||
}
|
||||
|
||||
showStatusPopup(msg, onDismiss) {
|
||||
showStatusPopup(msg, onDismiss, extra) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const root = this.add.container(0, 0).setDepth(D.modal);
|
||||
|
|
@ -493,8 +533,13 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
fontFamily: FONT, fontSize: '32px', color: COLORS.textHex,
|
||||
wordWrap: { width: 680 }, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
const onContinue = () => {
|
||||
btn.disableInteractive();
|
||||
const buttons = [];
|
||||
// Shared dismiss animation for whichever button is clicked — shrinks the
|
||||
// message into the status log and fades the popup out — differing only
|
||||
// in what runs afterward (`then`): resume the queue, or (for the extra
|
||||
// button) do something else that's responsible for resuming it itself.
|
||||
const dismiss = (then) => {
|
||||
buttons.forEach((b) => b.disableInteractive());
|
||||
// Detach without destroying — root sits at (0,0), so txt's numeric x/y
|
||||
// already equal its on-screen position; Phaser re-adds a removed child
|
||||
// straight to the scene's top-level display list, so it keeps rendering
|
||||
|
|
@ -520,10 +565,21 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
onComplete: () => root.destroy(true),
|
||||
});
|
||||
this.modalOpen = false;
|
||||
if (onDismiss) onDismiss(); else this.showNextStatus();
|
||||
then();
|
||||
};
|
||||
const btn = new Button(this, cx, cy + 100, 'CONTINUE', onContinue, { width: 220, height: 56 });
|
||||
const btnX = extra ? cx - 120 : cx;
|
||||
const btn = new Button(this, btnX, cy + 100, 'CONTINUE',
|
||||
() => dismiss(() => { if (onDismiss) onDismiss(); else this.showNextStatus(); }),
|
||||
{ width: 220, height: 56 });
|
||||
buttons.push(btn);
|
||||
root.add([dim, panel, txt, btn]);
|
||||
if (extra) {
|
||||
const extraBtn = new Button(this, cx + 120, cy + 100, extra.label,
|
||||
() => dismiss(() => extra.onClick()),
|
||||
{ width: 220, height: 56, variant: 'ghost' });
|
||||
buttons.push(extraBtn);
|
||||
root.add(extraBtn);
|
||||
}
|
||||
}
|
||||
|
||||
refreshHud() {
|
||||
|
|
@ -1166,15 +1222,19 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
// "resolve now, animate after" trick as the player's own moves, just
|
||||
// via a before/after snapshot instead of a waypoint path.
|
||||
const combatEvents = this.collectNewCombatEvents();
|
||||
// Combats against one of the human's own cities are always deferred to
|
||||
// a dedicated cinematic (camera pan + replay + outcome popup) played
|
||||
// at the start of the human's next turn, rather than silently replayed
|
||||
// here (possibly off-screen) like every other AI-vs-AI combat.
|
||||
// Combats against any city the human has explored — their own or a
|
||||
// rival's — are always deferred to a dedicated cinematic (camera pan +
|
||||
// replay + outcome popup) played at the start of the human's next
|
||||
// turn, rather than silently replayed here (possibly off-screen) like
|
||||
// ordinary field battles. City tiles stay marked once explored (see
|
||||
// MapView.refresh()'s `explored[idx]` check), so this reaches a known
|
||||
// rival city even if it's currently outside the fog-of-war vision set.
|
||||
const human = this.state.humanIndex;
|
||||
const elsewhere = [];
|
||||
for (const e of combatEvents) {
|
||||
const city = Logic.cityAt(this.state, e.x, e.y);
|
||||
if (city && city.civ === human) {
|
||||
const cityExplored = city && this.state.explored[human][Logic.tileIndex(this.state.world, city.x, city.y)];
|
||||
if (city && (city.civ === human || cityExplored)) {
|
||||
this.pendingCityAttacks.push({ e, cityId: city.id, cityName: city.name, x: city.x, y: city.y });
|
||||
} else {
|
||||
elsewhere.push(e);
|
||||
|
|
@ -1221,10 +1281,10 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// Combats against one of the human's own cities, deferred from
|
||||
// runToHumanTurn()'s per-civ stepping (see stepCiv there) so they always
|
||||
// get a camera pan + replay + outcome popup instead of possibly playing
|
||||
// silently off-screen alongside every other AI-vs-AI combat.
|
||||
// Combats against any city the human has explored (their own or a
|
||||
// rival's), deferred from runToHumanTurn()'s per-civ stepping (see
|
||||
// stepCiv there) so they always get a camera pan + replay + outcome popup
|
||||
// instead of possibly playing silently off-screen.
|
||||
announceCityAttacks() {
|
||||
const attacks = this.pendingCityAttacks;
|
||||
this.pendingCityAttacks = [];
|
||||
|
|
@ -1277,6 +1337,27 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
const other = e.a === human ? e.b : e.a;
|
||||
this.announceStatus(`You have made contact with ${this.state.civs[other].name}`,
|
||||
() => this.openDiplomacy({ focusCivId: other, playIntro: true }));
|
||||
} else if (e.type === 'buildingDone' && e.civ === human) {
|
||||
const city = Logic.cityById(this.state, e.cityId);
|
||||
const building = this.rules.buildings[e.building];
|
||||
if (!city || !building) continue;
|
||||
// completeBuild() already auto-picked a fallback next build (see
|
||||
// CivilizationLogic.js pickNextBuild) so the city doesn't idle —
|
||||
// VIEW CITY is here so the player can override that choice.
|
||||
this.announceStatus(`${building.name} completed in ${city.name}!`, undefined,
|
||||
(proceed) => { this.view.panToTile(city.x, city.y); this.time.delayedCall(500, proceed); },
|
||||
{
|
||||
label: 'VIEW CITY',
|
||||
onClick: () => {
|
||||
this.modalOpen = true;
|
||||
openCityScreen(this, this.rules, this.state, city, () => {
|
||||
this.modalOpen = false;
|
||||
this.view.refresh();
|
||||
this.refreshHud();
|
||||
this.showNextStatus();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -882,6 +882,19 @@ function resolveHut(rules, state, unit) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// Combat
|
||||
|
||||
// Easier-difficulty combat handicap: boosts the human side and softens the
|
||||
// AI side on Chieftain/Warlord; neutral (1.0/1.0/0.5) from Prince up, so
|
||||
// Prince stays the unmodified reference difficulty.
|
||||
function combatBonus(rules, state, civIdx) {
|
||||
const diff = rules.difficulties[state.difficultyId];
|
||||
return state.civs[civIdx].human ? diff.humanCombatBonus : diff.aiCombatBonus;
|
||||
}
|
||||
|
||||
function vetChance(rules, state, civIdx) {
|
||||
const diff = rules.difficulties[state.difficultyId];
|
||||
return state.civs[civIdx].human ? diff.humanVetChance : 0.5;
|
||||
}
|
||||
|
||||
export function defenderStrength(rules, state, defUnit, attacker) {
|
||||
const def = rules.units[defUnit.type];
|
||||
const attDef = rules.units[attacker.type];
|
||||
|
|
@ -902,12 +915,13 @@ export function defenderStrength(rules, state, defUnit, attacker) {
|
|||
if (attDef.domain === 'air' && city.buildings.sambattery) d *= rules.buildings.sambattery.value;
|
||||
}
|
||||
if (def.flags.includes('antimounted') && attDef.flags.includes('mounted')) d *= 2;
|
||||
d *= combatBonus(rules, state, defUnit.civ);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function attackerStrength(rules, state, unit) {
|
||||
const def = rules.units[unit.type];
|
||||
return def.attack * (unit.vet ? VET_BONUS : 1) * (unit.hp / def.hp);
|
||||
return def.attack * (unit.vet ? VET_BONUS : 1) * (unit.hp / def.hp) * combatBonus(rules, state, unit.civ);
|
||||
}
|
||||
|
||||
export function pickDefender(rules, state, x, y, attacker) {
|
||||
|
|
@ -982,7 +996,7 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
|
|||
for (const u of unitsAt(state, tx, ty).filter((un) => un.civ === defender.civ)) removeUnit(state, u);
|
||||
}
|
||||
attacker.hp = Math.max(1, duel.attackerHp);
|
||||
if (!attacker.vet && rand(state) < 0.5) attacker.vet = true;
|
||||
if (!attacker.vet && rand(state) < vetChance(rules, state, attacker.civ)) attacker.vet = true;
|
||||
if (!attDef.flags.includes('missile') && unitsAt(state, tx, ty).length === 0) {
|
||||
attacker.x = tx;
|
||||
attacker.y = ty;
|
||||
|
|
@ -993,7 +1007,7 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
|
|||
}
|
||||
} else {
|
||||
defender.hp = Math.max(1, duel.defenderHp);
|
||||
if (!defender.vet && rand(state) < 0.5) defender.vet = true;
|
||||
if (!defender.vet && rand(state) < vetChance(rules, state, defender.civ)) defender.vet = true;
|
||||
removeUnit(state, attacker);
|
||||
}
|
||||
spendMove(attacker, 3);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
computeVisible, isUnitVisibleTo, shieldGrassAt,
|
||||
} from './CivilizationLogic.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import { describeUnitStackTooltip } from './CivilizationTooltips.js';
|
||||
import { describeUnitStackTooltip, describeCityTooltip } from './CivilizationTooltips.js';
|
||||
|
||||
export const TILE_W = 128;
|
||||
export const TILE_H = 64;
|
||||
|
|
@ -603,6 +603,11 @@ export class CivilizationMapView {
|
|||
event.stopPropagation();
|
||||
this.cb.onCityClick?.(city);
|
||||
});
|
||||
// Hover info for other leaders' cities only — the human already gets
|
||||
// full detail on their own cities via onCityClick's city screen.
|
||||
if (city.civ !== this.humanIdx) {
|
||||
this.tooltip.attachTo(banner, () => describeCityTooltip(this.rules, civ, this.opponentsData, city));
|
||||
}
|
||||
this.dynamic.add(container);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { createOpponentPortrait } from '../../ui/Portrait.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import * as Logic from './CivilizationLogic.js';
|
||||
import { describeTechTooltip } from './CivilizationTooltips.js';
|
||||
|
||||
const FONT = '"Julius Sans One"';
|
||||
const ERAS = ['ancient', 'medieval', 'industrial', 'modern'];
|
||||
|
|
@ -55,6 +57,7 @@ export function openTechScreen(scene, rules, state, onClose) {
|
|||
maskShape.fillRect(left, top + 20, width - 60, height - 130);
|
||||
scrollArea.setMask(maskShape.createGeometryMask());
|
||||
|
||||
const tooltip = new Tooltip(scene, { depth: 70 });
|
||||
const available = new Set(Logic.availableTechs(rules, civ).map((t) => t.id));
|
||||
const colW = (width - 60) / 4;
|
||||
const rowH = 42;
|
||||
|
|
@ -83,20 +86,8 @@ export function openTechScreen(scene, rules, state, onClose) {
|
|||
}).setOrigin(0, 0.5);
|
||||
scrollArea.add(rect);
|
||||
scrollArea.add(txt);
|
||||
// Gate summary on hover.
|
||||
const g = rules.techGates[t.id];
|
||||
const unlocks = [
|
||||
...g.units.map((u) => rules.units[u].name),
|
||||
...g.buildings.map((b) => rules.buildings[b].name),
|
||||
...g.governments.map((gv) => rules.governments[gv].name),
|
||||
...g.improvements.map((im) => rules.improvements[im].name),
|
||||
];
|
||||
rect.setInteractive({ useHandCursor: canPick });
|
||||
rect.on('pointerover', () => {
|
||||
hoverText.setText(`${t.name}${t.prereqs.length ? ` ⟵ ${t.prereqs.map((p) => rules.techs[p].name).join(' + ')}` : ''}`
|
||||
+ (unlocks.length ? `\nUnlocks: ${unlocks.join(', ')}` : ''));
|
||||
});
|
||||
rect.on('pointerout', () => hoverText.setText(''));
|
||||
tooltip.attachTo(rect, () => describeTechTooltip(rules, t, civ));
|
||||
if (canPick) {
|
||||
rect.on('pointerdown', () => {
|
||||
Logic.setResearch(rules, state, civ, t.id);
|
||||
|
|
@ -106,11 +97,6 @@ export function openTechScreen(scene, rules, state, onClose) {
|
|||
});
|
||||
});
|
||||
|
||||
const hoverText = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + height / 2 - 46, '', {
|
||||
fontFamily: FONT, fontSize: '17px', color: COLORS.goldHex, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
root.add(hoverText);
|
||||
|
||||
// Wheel scroll for tall columns.
|
||||
const contentH = 70 + maxRows * rowH;
|
||||
const viewH = height - 130;
|
||||
|
|
@ -120,7 +106,10 @@ export function openTechScreen(scene, rules, state, onClose) {
|
|||
scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, Math.max(0, contentH - viewH));
|
||||
scrollArea.y = -scrollY;
|
||||
}
|
||||
root.once('destroy', () => scene.input.off('wheel', onWheel));
|
||||
root.once('destroy', () => {
|
||||
scene.input.off('wheel', onWheel);
|
||||
tooltip.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -55,6 +55,29 @@ const EFFECT_TEXT = {
|
|||
defensesea: (b) => `Multiplies this city's defense against naval attacks by ${b.value}.`,
|
||||
};
|
||||
|
||||
// Short blurbs for the handful of tech-unlocked governments — again hand
|
||||
// authored since the rules JSON carries only raw numeric fields, keyed off
|
||||
// what CivilizationLogic.js actually does with those fields (tradeBonus,
|
||||
// corruptionFactor, despotPenalty, freeUnits, unitUpkeep, warPenalty).
|
||||
const GOVERNMENT_TEXT = {
|
||||
monarchy: 'Cuts corruption well below Despotism and drops the Despotism penalty to tile yields.',
|
||||
communism: 'Low, distance-independent corruption anywhere in the empire, plus 6 free unit upkeep per city.',
|
||||
republic: 'Sharply reduces corruption and adds +1 trade to tiles already producing trade, at the cost of unit upkeep.',
|
||||
democracy: 'Eliminates corruption entirely and adds +1 trade to productive tiles; units cost gold upkeep and broken treaties cost double reputation.',
|
||||
};
|
||||
|
||||
// Short blurbs for tech-unlocked terrain improvements (worker orders), keyed
|
||||
// off what CivilizationLogic.js actually does with each one.
|
||||
const IMPROVEMENT_TEXT = {
|
||||
road: 'Cuts movement between roaded tiles to a flat 1 point and adds +1 trade to tiles already producing trade.',
|
||||
railroad: 'Movement between railroaded tiles is free, and adds +1 shield to tiles already producing shields.',
|
||||
irrigation: 'Adds food to the tile, boosting the city that works it.',
|
||||
farmland: 'With a Supermarket in the city, boosts food from irrigated tiles by 50%.',
|
||||
mine: 'Adds shields to the tile, boosting the city that works it.',
|
||||
fortress: 'Doubles a unit\'s defense on the tile and lets even a single unit protect it like a city would.',
|
||||
transform: 'Permanently changes the tile\'s terrain type (Engineers only).',
|
||||
};
|
||||
|
||||
export function describeUnitTooltip(rules, unit) {
|
||||
const lines = [
|
||||
{ text: `Attack ${unit.attack} · Defense ${unit.defense} · Move ${unit.move}`, color: COLORS.goldHex },
|
||||
|
|
@ -113,3 +136,72 @@ export function describeUnitStackTooltip(rules, civ, opponentsData, units) {
|
|||
icon: { texture: 'opponents', frame: opData?.spriteIndex ?? 0, color: civ.color, label: civ.name },
|
||||
};
|
||||
}
|
||||
|
||||
// Hover tooltip for a foreign city: which leader rules it plus the basic
|
||||
// facts visible from outside (population, capital/walls status) — no build
|
||||
// queue or yields, since this is a rival's city and this build has no
|
||||
// espionage/embassy system to justify seeing that.
|
||||
export function describeCityTooltip(rules, civ, opponentsData, city) {
|
||||
const opData = opponentsData?.find((o) => o.id === civ.leaderId);
|
||||
const lines = [
|
||||
{ text: civ.name, color: civ.color },
|
||||
{ text: `Population ${city.size}`, color: COLORS.goldHex },
|
||||
];
|
||||
if (city.buildings.palace) lines.push({ text: '• Capital' });
|
||||
if (city.buildings.citywalls) lines.push({ text: '• Defended by City Walls' });
|
||||
return {
|
||||
title: city.name,
|
||||
lines,
|
||||
icon: { texture: 'opponents', frame: opData?.spriteIndex ?? 0, color: civ.color, label: civ.name },
|
||||
};
|
||||
}
|
||||
|
||||
// Hover tooltip for a tech-tree entry: prerequisites (only worth showing
|
||||
// once — omitted for techs the civ already knows) followed by everything
|
||||
// the tech unlocks, grouped by kind. `civ` is used only to decide whether
|
||||
// the tech is already known/repeated, not to filter what's shown.
|
||||
export function describeTechTooltip(rules, tech, civ) {
|
||||
const known = !!civ.known[tech.id] || (tech.repeatable && civ.futureCount > 0);
|
||||
const g = rules.techGates[tech.id];
|
||||
const lines = [];
|
||||
|
||||
if (!known && tech.prereqs.length) {
|
||||
lines.push({
|
||||
text: `Requires: ${tech.prereqs.map((p) => rules.techs[p].name).join(' + ')}`,
|
||||
color: COLORS.mutedHex,
|
||||
});
|
||||
}
|
||||
|
||||
const section = (label, items) => {
|
||||
if (!items.length) return;
|
||||
lines.push({ text: label, color: COLORS.goldHex });
|
||||
for (const item of items) lines.push({ text: `• ${item}` });
|
||||
};
|
||||
|
||||
section('Units:', g.units.map((id) => {
|
||||
const u = rules.units[id];
|
||||
return `${u.name} (A${u.attack} D${u.defense} M${u.move})`;
|
||||
}));
|
||||
section('Buildings:', g.buildings.map((id) => {
|
||||
const b = rules.buildings[id];
|
||||
const describe = EFFECT_TEXT[b.effect];
|
||||
return describe ? `${b.name} — ${describe(b)}` : b.name;
|
||||
}));
|
||||
section('Government:', g.governments.map((id) => {
|
||||
const gv = rules.governments[id];
|
||||
const text = GOVERNMENT_TEXT[id];
|
||||
return text ? `${gv.name} — ${text}` : gv.name;
|
||||
}));
|
||||
section('Terrain improvements:', g.improvements.map((id) => {
|
||||
const im = rules.improvements[id];
|
||||
const text = IMPROVEMENT_TEXT[id];
|
||||
return text ? `${im.name} — ${text}` : im.name;
|
||||
}));
|
||||
section('Leads to further research:', g.prereqOf.map((id) => rules.techs[id].name));
|
||||
|
||||
if (tech.repeatable) {
|
||||
lines.push({ text: 'Repeatable — each additional discovery adds to your score.', color: COLORS.mutedHex });
|
||||
}
|
||||
|
||||
return { title: tech.name, lines };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,10 +251,13 @@ export const TUNE = {
|
|||
PULSAR_SPEED: 0.14,
|
||||
|
||||
// Rim flippers hunt the player: rest between hops, then a hop that can be
|
||||
// shot mid-flight. Ascending flippers also hop lanes from FLIP_CLIMB_LEVEL.
|
||||
// shot mid-flight. Flippers also flip lane-to-lane while still climbing
|
||||
// (their arcade signature) — chance ramps up and cooldown shortens with
|
||||
// level so early levels stay gentle and later ones get aggressive.
|
||||
FLIP_DUR_MS: 260,
|
||||
FLIP_REST_MS: 520, FLIP_REST_DECAY: 8, FLIP_REST_MIN_MS: 220,
|
||||
FLIP_CLIMB_LEVEL: 5, FLIP_CLIMB_CHANCE: 0.25, FLIP_CLIMB_COOLDOWN_MS: 1400,
|
||||
FLIP_CLIMB_CHANCE_BASE: 0.3, FLIP_CLIMB_CHANCE_GROWTH: 0.01, FLIP_CLIMB_CHANCE_MAX: 0.55,
|
||||
FLIP_CLIMB_COOLDOWN_MS: 1100, FLIP_CLIMB_COOLDOWN_DECAY: 20, FLIP_CLIMB_COOLDOWN_MIN_MS: 600,
|
||||
|
||||
SPIKE_MIN_T: 0.22, // how close to the rim a spike can grow
|
||||
SPIKE_TRIM: 0.06, // how much one shot shaves off a spike tip
|
||||
|
|
@ -317,6 +320,12 @@ export function spawnInterval(level) {
|
|||
export function flipRest(level) {
|
||||
return Math.max(TUNE.FLIP_REST_MIN_MS, TUNE.FLIP_REST_MS - TUNE.FLIP_REST_DECAY * (level - 1));
|
||||
}
|
||||
export function flipClimbChance(level) {
|
||||
return Math.min(TUNE.FLIP_CLIMB_CHANCE_MAX, TUNE.FLIP_CLIMB_CHANCE_BASE + TUNE.FLIP_CLIMB_CHANCE_GROWTH * (level - 1));
|
||||
}
|
||||
export function flipClimbCooldown(level) {
|
||||
return Math.max(TUNE.FLIP_CLIMB_COOLDOWN_MIN_MS, TUNE.FLIP_CLIMB_COOLDOWN_MS - TUNE.FLIP_CLIMB_COOLDOWN_DECAY * (level - 1));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Level color bands — the arcade cycles its palette every 16 levels
|
||||
|
|
@ -611,12 +620,14 @@ export class Sim {
|
|||
updateFlipper(e, dt) {
|
||||
if (e.state === 'climb') {
|
||||
e.t -= flipperSpeed(this.level) * (dt / 1000);
|
||||
// High-level flippers hop lanes on the way up, too.
|
||||
// Flippers hop lanes on the way up, not just at the rim — animated
|
||||
// the same way as a rim flip, then climbing resumes in the new lane.
|
||||
e.climbFlipCd -= dt;
|
||||
if (this.level >= TUNE.FLIP_CLIMB_LEVEL && e.climbFlipCd <= 0 && e.t > 0.15) {
|
||||
e.climbFlipCd = TUNE.FLIP_CLIMB_COOLDOWN_MS * (0.7 + this.rng() * 0.6);
|
||||
if (this.rng() < TUNE.FLIP_CLIMB_CHANCE) {
|
||||
e.lane = this.adjacentLaneToward(e.lane, this.playerLane());
|
||||
if (e.climbFlipCd <= 0 && e.t > 0.15) {
|
||||
e.climbFlipCd = flipClimbCooldown(this.level) * (0.7 + this.rng() * 0.6);
|
||||
const to = this.adjacentLaneToward(e.lane, this.playerLane());
|
||||
if (to !== e.lane && this.rng() < flipClimbChance(this.level)) {
|
||||
e.state = 'flip'; e.flipFrom = e.lane; e.flipTo = to; e.flipMs = 0; e.flipReturn = 'climb';
|
||||
}
|
||||
}
|
||||
if (e.t <= 0) {
|
||||
|
|
@ -629,7 +640,7 @@ export class Sim {
|
|||
if (e.restMs <= 0) {
|
||||
const to = this.adjacentLaneToward(e.lane, this.playerLane());
|
||||
if (to !== e.lane) {
|
||||
e.state = 'flip'; e.flipFrom = e.lane; e.flipTo = to; e.flipMs = 0;
|
||||
e.state = 'flip'; e.flipFrom = e.lane; e.flipTo = to; e.flipMs = 0; e.flipReturn = 'rest';
|
||||
} else {
|
||||
e.restMs = flipRest(this.level);
|
||||
}
|
||||
|
|
@ -638,8 +649,8 @@ export class Sim {
|
|||
e.flipMs += dt;
|
||||
if (e.flipMs >= TUNE.FLIP_DUR_MS) {
|
||||
e.lane = e.flipTo;
|
||||
e.state = 'rest';
|
||||
e.restMs = flipRest(this.level);
|
||||
e.state = e.flipReturn;
|
||||
if (e.state === 'rest') e.restMs = flipRest(this.level);
|
||||
}
|
||||
}
|
||||
// A rim flipper sharing the player's lane is a grab — instant death.
|
||||
|
|
|
|||
|
|
@ -705,6 +705,79 @@ if (RULES) {
|
|||
check('duel ordering sane', strong > fp && fp > even);
|
||||
}
|
||||
|
||||
// Difficulty combat handicap: Chieftain/Warlord ease combat for the human
|
||||
// side and soften the AI side; Prince/King/Emperor stay neutral (1.0/1.0).
|
||||
{
|
||||
const stPrince = makeFlatState();
|
||||
const humanP = Logic.spawnUnit(RULES, stPrince, 0, 'legion', 5, 5, null);
|
||||
const aiP = Logic.spawnUnit(RULES, stPrince, 1, 'legion', 5, 5, null);
|
||||
const humanAtkPrince = Logic.attackerStrength(RULES, stPrince, humanP);
|
||||
const aiAtkPrince = Logic.attackerStrength(RULES, stPrince, aiP);
|
||||
const humanDefPrince = Logic.defenderStrength(RULES, stPrince, humanP, aiP);
|
||||
const aiDefPrince = Logic.defenderStrength(RULES, stPrince, aiP, humanP);
|
||||
|
||||
for (const [diffId, expectHuman, expectAi] of [
|
||||
['chieftain', 1.25, 0.8],
|
||||
['warlord', 1.1, 0.9],
|
||||
]) {
|
||||
const st = makeFlatState();
|
||||
st.difficultyId = diffId;
|
||||
const human = Logic.spawnUnit(RULES, st, 0, 'legion', 5, 5, null);
|
||||
const ai = Logic.spawnUnit(RULES, st, 1, 'legion', 5, 5, null);
|
||||
const humanAtk = Logic.attackerStrength(RULES, st, human);
|
||||
const aiAtk = Logic.attackerStrength(RULES, st, ai);
|
||||
const humanDef = Logic.defenderStrength(RULES, st, human, ai);
|
||||
const aiDef = Logic.defenderStrength(RULES, st, ai, human);
|
||||
check(`${diffId} human attack x${expectHuman}`,
|
||||
Math.abs(humanAtk - humanAtkPrince * expectHuman) < 1e-9, `${humanAtk}`);
|
||||
check(`${diffId} ai attack x${expectAi}`,
|
||||
Math.abs(aiAtk - aiAtkPrince * expectAi) < 1e-9, `${aiAtk}`);
|
||||
check(`${diffId} human defense x${expectHuman}`,
|
||||
Math.abs(humanDef - humanDefPrince * expectHuman) < 1e-9, `${humanDef}`);
|
||||
check(`${diffId} ai defense x${expectAi}`,
|
||||
Math.abs(aiDef - aiDefPrince * expectAi) < 1e-9, `${aiDef}`);
|
||||
}
|
||||
|
||||
for (const diffId of ['king', 'emperor']) {
|
||||
const st = makeFlatState();
|
||||
st.difficultyId = diffId;
|
||||
const human = Logic.spawnUnit(RULES, st, 0, 'legion', 5, 5, null);
|
||||
const ai = Logic.spawnUnit(RULES, st, 1, 'legion', 5, 5, null);
|
||||
check(`${diffId} human attack unchanged from prince`,
|
||||
Math.abs(Logic.attackerStrength(RULES, st, human) - humanAtkPrince) < 1e-9);
|
||||
check(`${diffId} ai attack unchanged from prince`,
|
||||
Math.abs(Logic.attackerStrength(RULES, st, ai) - aiAtkPrince) < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
// Veteran-promotion odds: elevated for the human side on Chieftain, flat
|
||||
// 50% for AI regardless of difficulty, flat 50% for everyone at Prince+.
|
||||
{
|
||||
const trials = QUICK ? 1500 : 6000;
|
||||
function vetRate(difficultyId, civIdx) {
|
||||
let promos = 0;
|
||||
for (let i = 0; i < trials; i += 1) {
|
||||
const st = makeFlatState();
|
||||
st.difficultyId = difficultyId;
|
||||
st.rngState = (i * 2654435761) | 0;
|
||||
const attacker = Logic.spawnUnit(RULES, st, civIdx, 'armor', 4, 5, null);
|
||||
const defender = Logic.spawnUnit(RULES, st, 1 - civIdx, 'warriors', 5, 5, null);
|
||||
Logic.resolveAttack(RULES, st, attacker, 5, 5);
|
||||
if (attacker.vet) promos += 1;
|
||||
}
|
||||
return promos / trials;
|
||||
}
|
||||
const chieftainHuman = vetRate('chieftain', 0);
|
||||
const princeHuman = vetRate('prince', 0);
|
||||
const chieftainAi = vetRate('chieftain', 1);
|
||||
check('chieftain human vet rate ~0.75', chieftainHuman > 0.7 && chieftainHuman < 0.8,
|
||||
`${chieftainHuman.toFixed(3)}`);
|
||||
check('prince human vet rate ~0.5', princeHuman > 0.46 && princeHuman < 0.54,
|
||||
`${princeHuman.toFixed(3)}`);
|
||||
check('chieftain ai vet rate unchanged ~0.5', chieftainAi > 0.46 && chieftainAi < 0.54,
|
||||
`${chieftainAi.toFixed(3)}`);
|
||||
}
|
||||
|
||||
// Stack death outside cities/fortresses, survival inside.
|
||||
{
|
||||
const st = makeFlatState();
|
||||
|
|
|
|||
Loading…
Reference in New Issue