Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 2a08df9cd8 refactor starcontrol weapons and improve road visibility
- Rewrite Kohr-Ah guidedBlades: one-shot-per-press with blades that fly
  straight while held, then slow and become homing mines near enemies;
  blades persist indefinitely and cap at 8 live (oldest recycled)
- Add bladeFireHeld tracking and canFire/tick hooks for blade cooldown
- Update AI to tap fire for Kohr-Ah blades instead of holding
- Reduce PLANET_GRAVITY from 1.6e6 to 1.1e6 for better slingshot physics
- Add dark outline pass under road/rail lines in Civilization map for
  better visibility against light terrain
2026-07-16 14:44:52 -06:00
Brian Fertig 237a024f80 feat(civ): add status log panel, event announcements, smooth camera pan, and city sprites
- Add bottom-right status log panel with scrollable entries, word wrap,
  mask clipping, and mouse wheel scrolling; cap at 60 entries
- Replace toast popups with announceStatus() for major events (war, tech,
  first contact, city founded, trade routes, huts) — centered modal with
  CONTINUE button that animates into the log; supports queued popups and
  custom onDismiss callbacks (e.g. first contact opens diplomacy)
- Add smooth panToTile() tween for camera movement when selecting next unit,
  refactored clampPan into panBounds() reused by both clamp and tween
- Add classic city sprite sheet (civilization-cities-classic.png) and
  register it in artwork JSON and asset manifest
- Replace circular civ rings and selection ring with isometric ellipses
  matching the 2:1 tile ratio for better depth reading
- Paint tile neighbors in ascending c+r order during repaintTileAndNeighbors
  to prevent tall features from being overwritten by lower-sum neighbors
- Stop panTween on pointer down to avoid conflict with manual dragging
2026-07-16 13:48:39 -06:00
11 changed files with 262 additions and 40 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

View File

@ -14,6 +14,7 @@
"unitSheet": { "key": "civilization-units", "path": "assets/images/civilization/civilization-units.png", "frameWidth": 64, "frameHeight": 96 },
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },
"citySheets": {
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 }
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 },
"cyberpunk": { "key": "civilization-cities-cyberpunk", "path": "assets/images/civilization/civilization-cities-cyberpunk.png", "frameWidth": 128, "frameHeight": 96 }
}
}

View File

@ -9,6 +9,7 @@
"alphabet",
"pottery"
],
"citySheet": "classic",
"bio": "AI Bro sent from the future to show the superiority of the future.",
"voice": "Legion",
"convo": [
@ -50,6 +51,7 @@
"bronzeworking",
"thewheel"
],
"citySheet": "classic",
"bio": "Came for the honey, stayed for the salmon!",
"voice": "Jewel Weaver - Bear",
"convo": [
@ -87,6 +89,7 @@
"name": "Victor",
"trait": "pioneering",
"startingTechs": [],
"citySheet": "cyberpunk",
"bio": "Patient and calculating. Moves are made using ancient technology.",
"voice": "Starfield - Vasco",
"convo": [
@ -127,6 +130,7 @@
"bronzeworking",
"masonry"
],
"citySheet": "classic",
"bio": "Wassssuppp everybody! I'm here for the party!!",
"voice": "Batman - Killer Croc",
"convo": [
@ -168,6 +172,7 @@
"warriorcode",
"thewheel"
],
"citySheet": "classic",
"bio": "Can you find your way out of my maze of puzzles, games and fun!",
"speech": {
"intro": [
@ -195,6 +200,7 @@
"horsebackriding",
"masonry"
],
"citySheet": "classic",
"bio": "Thank you for visiting with me.",
"voice": "Betty White",
"convo": [
@ -235,6 +241,7 @@
"horsebackriding",
"pottery"
],
"citySheet": "classic",
"bio": "I play slow, I win fast!",
"voice": "SlowAltered",
"convo": [
@ -276,6 +283,7 @@
"warriorcode",
"masonry"
],
"citySheet": "classic",
"bio": "I'm here for the thrill of extreme victory!",
"voice": "Mass Effect - Commander Shepard",
"convo": [
@ -317,6 +325,7 @@
"thewheel",
"bronzeworking"
],
"citySheet": "classic",
"bio": "Come on let's play! I promise not to use my x-ray eyes.",
"voice": "Zero - Borderlands 3",
"convo": [
@ -358,6 +367,7 @@
"ceremonialburial",
"bronzeworking"
],
"citySheet": "classic",
"bio": "Having fun and playing is the name of the game for me!",
"speech": {
"intro": [
@ -385,6 +395,7 @@
"pottery",
"alphabet"
],
"citySheet": "classic",
"bio": "Well now yall know I want to play some them games!",
"voice": "Life is Strange - Jed Lucan",
"convo": [
@ -422,6 +433,7 @@
"name": "Natasha",
"trait": "pioneering",
"startingTechs": [],
"citySheet": "classic",
"bio": "Perhaps if I win, you will spill the secrets you've been keeping!",
"voice": "Street Fighter 6 - Marisa",
"convo": [
@ -462,6 +474,7 @@
"thewheel",
"alphabet"
],
"citySheet": "classic",
"bio": "Hocus Pocus Alacazam!!!!",
"convo": [
"terry",
@ -502,6 +515,7 @@
"masonry",
"ceremonialburial"
],
"citySheet": "classic",
"bio": "Woof! Woof!",
"speech": {
"intro": [
@ -529,6 +543,7 @@
"warriorcode",
"masonry"
],
"citySheet": "classic",
"bio": "Hey mon! Yous be knowin' I been wantin' ta play on da Fertig Games for a while now mon!",
"voice": "Djed Spence",
"convo": [
@ -568,6 +583,7 @@
"thewheel",
"alphabet"
],
"citySheet": "classic",
"bio": "Let's take a break on a warm summer day to spend time playing games!",
"voice": "Street Fighter 6 - Crimson Viper",
"speech": {
@ -600,6 +616,7 @@
"name": "Balam",
"trait": "pioneering",
"startingTechs": [],
"citySheet": "classic",
"bio": "Mystical Powers and a knack for games.",
"speech": {
"intro": [
@ -635,6 +652,7 @@
"masonry",
"bronzeworking"
],
"citySheet": "classic",
"bio": "I traveled WAY to far for this stupid Earth Internet.",
"speech": {
"intro": [
@ -669,6 +687,7 @@
"alphabet",
"pottery"
],
"citySheet": "classic",
"bio": "I ain't seen you 'round these parts before.",
"speech": {
"intro": [
@ -703,6 +722,7 @@
"masonry",
"alphabet"
],
"citySheet": "classic",
"bio": "Aaaaaaargh! Me be playin' these games matey!",
"voice": "pirate",
"speech": {
@ -738,6 +758,7 @@
"bronzeworking",
"horsebackriding"
],
"citySheet": "cyberpunk",
"bio": "She's here from the future to learn our games and study our culture.",
"voice": "Invincible VS - Atom Eve",
"speech": {
@ -774,6 +795,7 @@
"pottery",
"ceremonialburial"
],
"citySheet": "classic",
"bio": "Oooooooh yeah brother!!! You wanna play with The Smasher?!",
"speech": {
"intro": [
@ -805,6 +827,7 @@
"name": "DV-8-2303",
"trait": "pioneering",
"startingTechs": [],
"citySheet": "cyberpunk",
"bio": "Cybernetic Systems online. Activate human defeat protocol... EOF",
"speech": {
"intro": [
@ -839,6 +862,7 @@
"pottery",
"ceremonialburial"
],
"citySheet": "classic",
"bio": "I've disabled the firewall protections on this website. Now to hack the core!",
"voice": "Life is Strange - Rachael Amber",
"speech": {
@ -874,6 +898,7 @@
"masonry",
"pottery"
],
"citySheet": "classic",
"bio": "I have traveled far to play these games. Honor me and let us play!",
"speech": {
"intro": [
@ -908,6 +933,7 @@
"ceremonialburial",
"masonry"
],
"citySheet": "classic",
"bio": "I haven't had this much fun since the Mezasoic era!",
"speech": {
"intro": [
@ -942,6 +968,7 @@
"ceremonialburial",
"horsebackriding"
],
"citySheet": "classic",
"bio": "Ahoy you land lovers!",
"voice": "Defiant Calder",
"convo": [
@ -983,6 +1010,7 @@
"pottery",
"thewheel"
],
"citySheet": "classic",
"bio": "Bucky Beaver and I are there to TRAP all the fun and games!",
"speech": {
"intro": [
@ -1017,6 +1045,7 @@
"pottery",
"thewheel"
],
"citySheet": "classic",
"bio": "From a different galaxy, but has the same great Earth based lifeforms taste for games!",
"speech": {
"intro": [
@ -1051,6 +1080,7 @@
"horsebackriding",
"thewheel"
],
"citySheet": "cyberpunk",
"bio": "MISSION OBJECTIVE: Victory.... all other priorities recinded.",
"speech": {
"intro": [

View File

@ -67,6 +67,7 @@ export default class CivilizationGame extends Phaser.Scene {
this.logEntries = [];
this.logScrollY = 0;
this.statusQueue = [];
this.pendingCityAttacks = [];
}
create() {
@ -292,7 +293,9 @@ 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, trait: op.trait, startingTechs: op.startingTechs }));
.map((op) => ({
id: op.id, name: op.name, trait: op.trait, startingTechs: op.startingTechs, citySheet: op.citySheet,
}));
const seed = (Date.now() % 1000000) + 1;
try {
this.state = Logic.createGame(this.rules, {
@ -457,16 +460,30 @@ export default class CivilizationGame extends Phaser.Scene {
// popup once this one is dismissed (e.g. first-contact opens the diplomacy
// screen next) — that callback is then responsible for resuming the queue
// itself once IT closes (see openDiplomacy()'s onClose).
announceStatus(msg, onDismiss) {
this.statusQueue.push({ msg, onDismiss });
//
// `pre`, if given, is a cinematic step (e.g. pan the camera + replay a
// combat) that runs BEFORE the text popup appears — it receives a
// `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 });
if (!this.modalOpen) this.showNextStatus();
}
showNextStatus() {
const item = this.statusQueue.shift();
if (!item) return;
const { msg, onDismiss } = item;
// Set modalOpen up front, even though the popup itself isn't built until
// `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);
if (pre) pre(proceed); else proceed();
}
showStatusPopup(msg, onDismiss) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const root = this.add.container(0, 0).setDepth(D.modal);
@ -613,10 +630,22 @@ export default class CivilizationGame extends Phaser.Scene {
}
if (myUnits.length && (!sel || sel.x !== c || sel.y !== r)) {
// A unit is already active and the player clicked a DIFFERENT tile
// with one of their own units on it — ambiguous whether they meant to
// send the active unit there (joining that tile's stack) or take
// control of the unit they clicked, so ask instead of guessing.
if (sel && state.current === human) {
this.openUnitInteractionMenu(sel, myUnits, c, r);
return;
}
this.selectUnit(myUnits[0]);
return;
}
if (myUnits.length && sel && sel.x === c && sel.y === r) {
if (myUnits.length > 1 && state.current === human) {
this.openStackSwitchMenu(sel, myUnits);
return;
}
// Cycle the stack.
const i = myUnits.indexOf(sel);
this.selectUnit(myUnits[(i + 1) % myUnits.length]);
@ -981,10 +1010,40 @@ export default class CivilizationGame extends Phaser.Scene {
}
openUnitActionMenu(unit) {
this.modalOpen = true;
const def = this.rules.units[unit.type];
const actions = this.getUnitActions(unit);
this.openActionListMenu(def.name, this.getUnitActions(unit));
}
// Clicked a DIFFERENT tile that has one or more of the player's own units
// while a unit is already selected — offer to move the selected unit there
// (joining whatever's on that tile) or to switch active control to one of
// the units already there.
openUnitInteractionMenu(sel, targetUnits, c, r) {
const actions = [
{ label: 'Move', onSelect: () => this.moveTo(sel, c, r) },
...targetUnits.map((u) => ({
label: `Switch to ${this.rules.units[u.type].name}`,
onSelect: () => this.selectUnit(u),
})),
];
this.openActionListMenu(this.rules.units[targetUnits[0].type].name, actions);
}
// Clicked the SAME tile the selected unit is already stacked on — offer to
// switch active control to one of the other units there, or "Join" to
// leave the current selection as-is (they're already stacked together).
openStackSwitchMenu(sel, stackUnits) {
const actions = stackUnits.filter((u) => u.id !== sel.id).map((u) => ({
label: `Switch to ${this.rules.units[u.type].name}`,
onSelect: () => this.selectUnit(u),
}));
this.openActionListMenu('Unit Stack', actions, 'JOIN');
}
// Shared list-of-buttons modal used by openUnitActionMenu (clicking your
// own selected unit) and openUnitInteractionMenu (clicking another unit).
openActionListMenu(title, actions, cancelLabel = 'CANCEL') {
this.modalOpen = true;
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const itemH = 52;
@ -997,16 +1056,16 @@ export default class CivilizationGame extends Phaser.Scene {
const root = this.add.container(0, 0).setDepth(D.modal);
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive();
const panel = this.add.rectangle(cx, py + ph / 2, pw, ph, COLORS.panel).setStrokeStyle(2, COLORS.accent);
const title = this.add.text(cx, py + 34, def.name, {
const titleText = this.add.text(cx, py + 34, title, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.accentHex,
}).setOrigin(0.5);
root.add([dim, panel, title]);
root.add([dim, panel, titleText]);
// This menu opens from a pointerdown on the unit's sprite, so the mouse
// button is typically still held when these buttons appear. Selected
// units are frequently screen-centered (selectNextUnit centers the view
// on them), same as this menu, so the imminent mouse-up can land right
// on a button and fire it before the player ever sees the menu. Ignore
// Callers open this from a pointerdown/click, so the mouse button is
// often still held when these buttons appear (e.g. selected units are
// frequently screen-centered — selectNextUnit centers the view on them —
// same as this menu, so the imminent mouse-up can land right on a
// button and fire it before the player ever sees the menu). Ignore
// clicks until that one release has passed.
let armed = !this.input.activePointer.isDown;
const arm = () => { armed = true; };
@ -1029,7 +1088,7 @@ export default class CivilizationGame extends Phaser.Scene {
});
const cancelY = py + 76 + actions.length * (itemH + gap) + 8;
const cancel = new Button(this, cx, cancelY + 20, 'CANCEL', () => {
const cancel = new Button(this, cx, cancelY + 20, cancelLabel, () => {
if (!armed) return;
close();
}, { width: pw - 48, height: 44, fontSize: 16, variant: 'ghost' });
@ -1063,6 +1122,7 @@ export default class CivilizationGame extends Phaser.Scene {
this.openTech();
}
this.presentAIProposals();
this.announceCityAttacks();
this.announceEvents();
this.view.refresh();
this.refreshHud();
@ -1103,7 +1163,21 @@ 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();
const visibleCombat = combatEvents.filter((e) => this.view.isTileVisible(e.ax, e.ay)
// 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.
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) {
this.pendingCityAttacks.push({ e, cityId: city.id, cityName: city.name, x: city.x, y: city.y });
} else {
elsewhere.push(e);
}
}
const visibleCombat = elsewhere.filter((e) => this.view.isTileVisible(e.ax, e.ay)
|| this.view.isTileVisible(e.x, e.y));
const combatUnitIds = new Set(combatEvents.flatMap((e) => [e.attackerId, e.defenderId]));
const moves = [];
@ -1144,6 +1218,24 @@ 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.
announceCityAttacks() {
const attacks = this.pendingCityAttacks;
this.pendingCityAttacks = [];
for (const atk of attacks) {
const { e, cityName, x, y } = atk;
const attackerName = this.state.civs[e.attackerCiv]?.name ?? 'An enemy';
const outcome = e.attackerWon ? `${cityName}'s defenders were defeated!` : `${cityName} held its ground!`;
this.announceStatus(`${attackerName} attacked ${cityName}! ${outcome}`, undefined, (proceed) => {
this.view.panToTile(x, y);
this.view.animateCombat([e], proceed);
});
}
}
announceEvents() {
const human = this.state.humanIndex;
for (const e of this.state.events) {
@ -1154,7 +1246,26 @@ export default class CivilizationGame extends Phaser.Scene {
} else if (e.type === 'techDone' && e.civ === human) {
this.announceStatus(`Research complete: ${this.rules.techs[e.tech].name}`);
} else if (e.type === 'cityCaptured' && (e.from === human || e.to === human)) {
this.announceStatus(e.to === human ? `You captured ${e.name}!` : `${e.name} has fallen!`);
if (e.to === human) {
this.announceStatus(`You captured ${e.name}!`);
} else {
// Losing a city gets the same camera-pan-then-replay treatment as
// a defended attack (see announceCityAttacks) — this covers the
// undefended-city case, which never generates a 'combat' event at
// all (see captureCity in CivilizationLogic.js), so this is the
// only place that ever shows anything for it.
const conqueror = this.state.civs[e.to]?.name ?? 'the enemy';
const cityRef = Logic.cityById(this.state, e.cityId);
this.announceStatus(`${e.name} has fallen to ${conqueror}!`, undefined, (proceed) => {
if (!cityRef) { proceed(); return; }
this.view.panToTile(cityRef.x, cityRef.y);
if (e.attackerType) {
this.view.animateCityFall(e.attackerCiv, e.attackerType, cityRef.x, cityRef.y, proceed);
} else {
this.time.delayedCall(500, proceed);
}
});
}
} else if (e.type === 'civEliminated') {
this.announceStatus(`${this.state.civs[e.civ].name} has been destroyed`);
} else if (e.type === 'spaceshipLaunched') {

View File

@ -96,6 +96,7 @@ export function createGame(rules, opts) {
id: i,
leaderId: leaders[i].id,
traitId: leaders[i].trait ?? null,
citySheet: leaders[i].citySheet ?? 'classic',
name: leaders[i].name,
color: rules.playerColors[i % rules.playerColors.length],
human: i === humanIndex,
@ -1033,7 +1034,10 @@ export function captureCity(rules, state, unit, city) {
const remaining = civCities(state, oldCiv.id);
if (wasCapital && remaining.length) remaining[0].buildings.palace = true;
state.events.push({ type: 'cityCaptured', cityId: city.id, name: city.name, from: oldCiv.id, to: newCivIdx });
state.events.push({
type: 'cityCaptured', cityId: city.id, name: city.name, from: oldCiv.id, to: newCivIdx,
attackerCiv: unit.civ, attackerType: unit.type,
});
if (!remaining.length) eliminateCiv(rules, state, oldCiv.id);
checkVictory(rules, state);
return { result: 'captured', cityId: city.id };

View File

@ -344,7 +344,7 @@ export class CivilizationMapView {
if (bits & (IMP.ROAD | IMP.RAILROAD)) {
g.clear();
const rail = !!(bits & IMP.RAILROAD);
let drewAny = false;
const segments = [];
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx === 0 && dy === 0) continue;
@ -355,21 +355,24 @@ export class CivilizationMapView {
if (!(nBits & (IMP.ROAD | IMP.RAILROAD))) continue;
const ex = (this.isoX(nc, nr) - this.isoX(c, r)) / 2;
const ey = (this.isoY(nc, nr) - this.isoY(c, r)) / 2;
g.lineStyle(rail ? 4 : 3, rail ? 0x4a4038 : 0x8a6f4d, 0.9);
g.beginPath();
g.moveTo(0, cy);
g.lineTo(ex, cy + ey);
g.strokePath();
drewAny = true;
segments.push([ex, cy + ey]);
}
}
if (!drewAny && !hasCity) {
g.lineStyle(3, 0x8a6f4d, 0.9);
g.beginPath();
g.moveTo(-14, cy);
g.lineTo(14, cy);
g.strokePath();
}
if (!segments.length && !hasCity) segments.push([-14, cy], [14, cy]);
// A dark outline pass under the road/rail colour itself makes the line
// read clearly against any terrain, instead of blending in on lighter
// ground like desert/plains.
const strokeAll = (width, color, alpha) => {
g.lineStyle(width, color, alpha);
for (const [ex, ey] of segments) {
g.beginPath();
g.moveTo(0, cy);
g.lineTo(ex, ey);
g.strokePath();
}
};
strokeAll(rail ? 7 : 6, 0x000000, 0.4);
strokeAll(rail ? 4 : 3, rail ? 0x4a4038 : 0x8a6f4d, 1);
this.drawStampAt(g, x, y);
}
@ -549,7 +552,7 @@ export class CivilizationMapView {
const container = scene.add.container(x, y).setDepth(depthY);
const walled = !!city.buildings.citywalls;
const themeKey = 'civilization-cities-classic';
const themeKey = `civilization-cities-${civ.citySheet ?? 'classic'}`;
if (scene.textures.exists(themeKey)) {
const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0;
const img = scene.add.image(0, TILE_H - FRAME_H + 48, themeKey, walled ? 4 + tier : tier);
@ -786,6 +789,33 @@ export class CivilizationMapView {
return { container, img, roundel, color };
}
// Undefended-city capture has no duel to replay (captureCity in
// CivilizationLogic.js never pushes a combat event) — just the attacker's
// ghost popping into the city, holding a moment, then fading, to at least
// show who took it before the outcome popup appears.
animateCityFall(civIdx, unitType, c, r, onDone) {
const ghost = this.buildCombatGhost(civIdx, unitType, c, r);
ghost.container.setScale(0.4);
ghost.container.setAlpha(0);
this.scene.tweens.add({
targets: ghost.container,
scale: 1,
alpha: 1,
duration: 300,
ease: 'Back.easeOut',
onComplete: () => {
this.scene.time.delayedCall(500, () => {
this.scene.tweens.add({
targets: ghost.container,
alpha: 0,
duration: 300,
onComplete: () => { ghost.container.destroy(); onDone?.(); },
});
});
},
});
}
// Tints the loser bright red (setTint on sprite art; a manual red redraw
// for the procedural-roundel fallback, which has no Tint component),
// flashes it a few times, then shrinks it away and destroys it.

View File

@ -340,6 +340,11 @@ export function updateAI(ai, state, dtMs) {
&& !(enemy.fx.cloaked && dist > 900)) {
input.fire = true;
}
// Kohr-Ah blades are one-shot-per-press: the ship must let go of the
// button before it can launch another, so tap it instead of holding it.
if (ship.def.primary.type === 'guidedBlades' && ship.fx.bladeFireHeld) {
input.fire = false;
}
// Charge weapons (Melnorme pump-up): keep pumping while far or unaimed,
// release once the shot is charged and the nose is on target.
if (ship.fx.pump) {

View File

@ -22,7 +22,7 @@ export const FACING_STEP = (Math.PI * 2) / FACINGS;
export const TUNE = {
MAX_FRAMES_PER_STEP: 6, // spiral-of-death cap after tab suspend
PLANET_RADIUS: 220, // world units (UQM planet ~29 display px)
PLANET_GRAVITY: 1.6e6, // accel = G / r^2 (world units / frame^2)
PLANET_GRAVITY: 1.1e6, // accel = G / r^2 (world units / frame^2)
PLANET_GRAVITY_MIN_R: 420, // clamp radius so slingshots stay sane
PLANET_CREW_DAMAGE: 3, // crew lost on planet impact
PLANET_DAMAGE_COOLDOWN: 24, // frames between crew-damaging planet hits

View File

@ -318,23 +318,56 @@ export const PRIMARIES = {
},
},
// Kohr-Ah spinning blades: launched forward, then steered toward wherever
// the Marauder points while the button is held.
// Kohr-Ah spinning blades: one shot per button press (the button must be
// released before another blade can launch), launched dead ahead and left
// flying straight while the button stays held. Releasing lets a blade
// coast to a stop and then drift as a slow homing mine toward any enemy
// that strays close, otherwise it just sits there as a trap. Blades never
// expire on their own — only dying to a ship, asteroid, planet or another
// blade — but only maxCount (default 8) may be live at once; launching
// past that cap pops the oldest.
guidedBlades: {
fire(state, ship, def) {
ship.fx.bladeFireHeld = true;
const m = muzzle(ship, (def.muzzleDist ?? 9) * 4, 0);
spawnProjectile(state, ship, {
const blade = spawnProjectile(state, ship, {
type: 'blade',
x: m.x, y: m.y, angle: m.angle,
speed: def.speed,
damage: def.damage, hp: def.hp ?? 10, life: def.lifeFrames,
damage: def.damage, hp: def.hp ?? 10, life: Infinity,
radius: def.radius ?? 30,
data: { trackRange: (def.trackRange ?? 224) * 4, wait: 0 },
behavior(st, p) {
const owner = st.ships[p.owner];
if (!owner.alive || !owner.input.fire) return;
steerProjectile(p, facingToRad(quantizeFacing(owner.facing)), FACING_STEP / 2, def.speed);
if (owner.alive && owner.input.fire) return;
if (Math.abs(p.vx) > 1 || Math.abs(p.vy) > 1) {
p.vx *= 0.5; p.vy *= 0.5;
return;
}
p.vx = 0; p.vy = 0;
if (p.data.wait > 0) { p.data.wait -= 1; return; }
p.data.wait = 4;
const enemy = enemyOf(st, owner);
if (!enemy.alive || tdist(p.x, p.y, enemy.x, enemy.y) > p.data.trackRange) return;
const a = bearingTo(p, enemy);
p.vx = Math.cos(a) * 8; p.vy = Math.sin(a) * 8;
},
});
const blades = (ship.fx.blades ?? []).filter((b) => b.alive);
blades.push(blade);
const max = def.maxCount ?? 8;
while (blades.length > max) {
const oldest = blades.shift();
oldest.alive = false;
emit(state, { t: 'shotHit', x: oldest.x, y: oldest.y, kind: 'blade' });
}
ship.fx.blades = blades;
},
canFire(state, ship) {
return !ship.fx.bladeFireHeld;
},
tick(state, ship) {
if (!ship.input.fire) ship.fx.bladeFireHeld = false;
},
},

View File

@ -193,6 +193,10 @@ 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);
let cityThemes = null;
try {
cityThemes = JSON.parse(readFileSync(join(root, 'data/civilization-artwork.json'), 'utf8')).citySheets;
} catch { /* checked separately in section 1; opponents citySheet check just skips below */ }
if (RULES) {
for (const op of opponentsJson.opponents ?? []) {
check(`opponent ${op.id} has trait`, typeof op.trait === 'string' && op.trait.length > 0);
@ -201,6 +205,10 @@ if (RULES) {
for (const techId of op.startingTechs ?? []) {
check(`opponent ${op.id} startingTechs ${techId} known tech`, !!RULES.techs[techId]);
}
if (cityThemes) {
check(`opponent ${op.id} has citySheet`, typeof op.citySheet === 'string' && op.citySheet.length > 0);
check(`opponent ${op.id} citySheet resolves`, !!cityThemes[op.citySheet], op.citySheet);
}
const traitTechs = RULES.civTraits[op.trait]?.startingTechs ?? [];
const total = new Set([...traitTechs, ...(op.startingTechs ?? [])]).size;
check(`opponent ${op.id} has 2-3 total starting techs`, total === 2 || total === 3, `${total}`);