diff --git a/src/games/blockilization/client/concepts.ts b/src/games/blockilization/client/concepts.ts index cb2d07b..b3c7403 100644 --- a/src/games/blockilization/client/concepts.ts +++ b/src/games/blockilization/client/concepts.ts @@ -124,6 +124,7 @@ export const CONCEPTS: Article[] = [ { title: 'Terrain and position', text: 'Defending on hills, in forest or jungle is stronger; marsh and open flood plains are weaker. Fortifying (F) adds to defence each turn up to +50%. Each of your other units next to the target adds 10% to an attack (flanking). A [[unit:great_general|Great General]] adds 15% to units within two tiles.' }, { title: 'Zones of control', text: 'Stepping from one tile next to an enemy unit or city to another next to it ends a unit’s move. Crossing a river without a bridge ([[tech:construction|Construction]]) or embarking takes all its moves.' }, { title: 'Experience', text: 'Fighting earns experience, and at each step a unit takes a [[cat:promotion|promotion]]. Barracks and the like start new units with experience.' }, + { title: 'Line of sight', text: 'A ranged unit fires at what it can see. Next door it always can; further off, forest, jungle, hills and mountains block its view of what lies behind them (not the tile itself), though a line running past the edge of one can look round it from two tiles away. From a hill a unit sees over forest and other hills, but not over wooded hills or mountains. With Indirect Fire (Artillery, Rocket Artillery and the Battleship have it, and archers, siege units and ships can earn it) a unit fires over all of it, at anything your units see.' }, { title: 'Cities', text: 'Siege units hit cities twice as hard; mounted and armoured units are weaker against them. A city fires once a turn at a unit within two tiles.' }, { title: 'Corps and armies', text: 'From the Industrial Era, units of a kind side by side can join as one, far stronger: see [[concept:formations|Corps and armies]].' }, ], @@ -335,6 +336,7 @@ export const ABILITY_TEXT: Record = { bonus_vs_cities: '+100% strength against cities.', penalty_vs_cities: '-33% strength against cities.', must_setup: 'Must set up (a move) before it can fire.', + indirect_fire: 'Indirect Fire: fires over hills, forest, jungle and mountains in the way, at anything your units see.', no_attack: 'Cannot attack.', coast_only: 'Cannot enter the open ocean.', see_submarines: 'Sees submarines.', diff --git a/src/games/blockilization/client/pedia.ts b/src/games/blockilization/client/pedia.ts index 53dc456..5a5cf21 100644 --- a/src/games/blockilization/client/pedia.ts +++ b/src/games/blockilization/client/pedia.ts @@ -414,7 +414,7 @@ function unitPage(u: UnitType): Page { if (abilities.length) body.push(h('ul.pd-list', {}, ...abilities.map((a) => h('li', {}, a)))); const replacedBy = UNIT_LIST.filter((x) => x.replaces === u.id).map((x) => `unit:${x.id}`); const from = UNIT_LIST.filter((x) => x.upgrade === u.id).map((x) => `unit:${x.id}`); - const promos = u.class === 'civilian' || u.class === 'great' || u.class === 'trade' || u.class === 'religious' ? [] : PROMOTION_LIST.filter((p) => p.classes.includes(u.class) && !p.needs?.length).map((p) => `promotion:${p.id}`); + const promos = u.class === 'civilian' || u.class === 'great' || u.class === 'trade' || u.class === 'religious' ? [] : PROMOTION_LIST.filter((p) => p.classes.includes(u.class) && !p.needs?.length && !p.needsOne?.length).map((p) => `promotion:${p.id}`); return { sub: u.civ ? `${CIVS[u.civ]?.name} · unique unit` : u.unbuildable ? 'Not built in cities' : `${ERA_NAMES[eraOfTech(u.tech)]} unit`, facts, @@ -755,7 +755,8 @@ function promotionPage(id: string): Page { body: [rich(p.text), rich('A unit takes a promotion each time it has the experience for one: at 10, 30, 60, 100 and so on.')], related: [ ['Needs', (p.needs ?? []).map((n) => `promotion:${n}`)], - ['Leads to', PROMOTION_LIST.filter((x) => x.needs?.includes(id)).map((x) => `promotion:${x.id}`)], + ['Needs one of', (p.needsOne ?? []).map((n) => `promotion:${n}`)], + ['Leads to', PROMOTION_LIST.filter((x) => x.needs?.includes(id) || x.needsOne?.includes(id)).map((x) => `promotion:${x.id}`)], ], }; } diff --git a/src/games/blockilization/client/store.ts b/src/games/blockilization/client/store.ts index 7d2ac57..428cd9c 100644 --- a/src/games/blockilization/client/store.ts +++ b/src/games/blockilization/client/store.ts @@ -1,13 +1,14 @@ import { unpackKnown, unpackStatic, unpackYields, type CityInfo, type DealCheck, type KnownTile, type LobbyView, type MapWire, type ReshapeWire, type PreviewAsk, type ResearchAdvice, type StatsInfo, type UnitWire, type View } from '../protocol'; +import { indirectFire } from '../rules/combat'; import type { DealItem } from '../rules/diplomacy'; -import { distance, line, type Grid } from '../rules/hex'; +import { clear, distance, ray, type Grid } from '../rules/hex'; import type { Note } from '../rules/state'; import type { Lens } from './lenses'; import type { Reaction } from './leaderscene'; import { prefs } from './prefs'; import type { CombatPreview } from '../rules/preview'; import { PROMOTIONS } from '../rules/promotions'; -import type { Yields } from '../rules/terrain'; +import { seeFromLevel, seeThroughLevel, type Yields } from '../rules/terrain'; import { isGreatPerson, isMilitary, UNITS } from '../rules/units'; /** @@ -339,17 +340,18 @@ export function nukeable(u: UnitWire, t: number): boolean { return enemyAt(t) || (owner >= 0 && owner !== me() && !!v.players[owner]?.war); } -/** - * Whether a unit could attack a tile from where it stands: a fighting unit, an enemy in sight there, - * in reach; and for a shot past the next tile, nothing in the way (hills, forest, jungle or mountains - * between, unless it fires from a hill), as the rules' `canFire` has it. Aircraft need no line of sight. - */ /** Why a ranged unit can't fire at an enemy it's pointed at: too far, or something in the way of its shot. */ export function outOfReach(u: UnitWire, t: number): string { const g = store.grid; - return g && distance(g, u.tile, t) <= reachOf(u) ? 'Hills, forest or jungle block its shot' : `Out of range (${reachOf(u)})`; + return g && distance(g, u.tile, t) <= reachOf(u) ? 'Hills, forest, jungle or mountains block its shot' : `Out of range (${reachOf(u)})`; } +/** + * Whether a unit could attack a tile from where it stands: a fighting unit, an enemy in sight there, + * in reach; and for a shot past the next tile, a line of sight to it, as the rules' `canFire` has it + * (`inSight`: what stands higher than the shooter blocks, unless it has Indirect Fire). Aircraft need + * no line of sight. + */ export function attackable(u: UnitWire, t: number): boolean { const type = UNITS[u.type]; const g = store.grid; @@ -368,13 +370,12 @@ export function attackable(u: UnitWire, t: number): boolean { } const d = distance(g, u.tile, t); if (d > reachOf(u)) return false; - if (d <= 1 || type.domain === 'air' || store.terrain[u.tile]?.elevation === 'hills') return true; - return line(g, u.tile, t) - .slice(1, -1) - .every((x) => { - const s = store.terrain[x]; - return !!s && s.elevation !== 'mountain' && s.elevation !== 'hills' && s.feature !== 'forest' && s.feature !== 'jungle'; - }); + if (d <= 1 || type.domain === 'air' || indirectFire(u)) return true; + const from = store.terrain[u.tile]; + if (!from) return false; + const eye = seeFromLevel(from); + // (A tile not known blocks it, as the edge of the map does.) + return clear(ray(g, u.tile, t), (x) => !store.terrain[x] || seeThroughLevel(store.terrain[x]) > eye); } /** Ask before doing something drastic: a dialog of the game's own, `then` on yes. */ diff --git a/src/games/blockilization/rules/ai/military.ts b/src/games/blockilization/rules/ai/military.ts index 78b7d06..e13f331 100644 --- a/src/games/blockilization/rules/ai/military.ts +++ b/src/games/blockilization/rules/ai/military.ts @@ -1,5 +1,5 @@ import { sizeOf } from '../armies'; -import { canFire, canPillage, cityFireStrength, cityMaxHp, cityStrength, rangeOf, strength } from '../combat'; +import { canFire, canPillage, cityFireStrength, cityMaxHp, cityStrength, indirectFire, rangeOf, strength } from '../combat'; import { canStand, citiesOf } from '../core'; import { weighing } from '../economy'; import { neighbours, within } from '../hex'; @@ -151,7 +151,7 @@ function bestStrike(ctx: Ctx, u: Unit, garrison: boolean): Strike | null { if (needsSetup && u.moves < 2) return null; const range = rangeOf(u); // (A mounted archer next to its target would charge instead of shooting: only from range.) - for (const t of within(game.grid, u.tile, range)) if (t !== u.tile && canFire(game, u.tile, t, range) && !(type.class === 'mounted' && dist(ctx, u.tile, t) === 1)) consider(t, u.tile, needsSetup); + for (const t of within(game.grid, u.tile, range)) if (t !== u.tile && canFire(game, u.tile, t, range, indirectFire(u)) && !(type.class === 'mounted' && dist(ctx, u.tile, t) === 1)) consider(t, u.tile, needsSetup); return best; } if (garrison) return null; @@ -404,7 +404,7 @@ export function moveUnits(ctx: Ctx, garrisons: Set) { goTo(ctx, u, camp, 'camp'); continue; } - const spot = freeSpot(ctx, u, camp, fires(u) ? 2 : 1, claimed, (t) => t !== camp && (!fires(u) || canFire(game, t, camp, rangeOf(u)))); + const spot = freeSpot(ctx, u, camp, fires(u) ? 2 : 1, claimed, (t) => t !== camp && (!fires(u) || canFire(game, t, camp, rangeOf(u), indirectFire(u)))); if (spot === undefined) continue; claimed.add(spot); goTo(ctx, u, spot, 'camp'); @@ -571,7 +571,7 @@ function siege(ctx: Ctx, army: Unit[], claimed: Set) { if (!u.embarked && !sameLand(game, u.tile, target.tile)) continue; if (fires(u)) { const r = rangeOf(u); - const spot = freeSpot(ctx, u, target.tile, r, claimed, (t) => dist(ctx, t, target.tile) >= (type.class === 'mounted' ? 2 : Math.min(2, r)) && canFire(game, t, target.tile, r)); + const spot = freeSpot(ctx, u, target.tile, r, claimed, (t) => dist(ctx, t, target.tile) >= (type.class === 'mounted' ? 2 : Math.min(2, r)) && canFire(game, t, target.tile, r, indirectFire(u))); if (spot === undefined) { if (far > r + 1) goTo(ctx, u, target.tile, 'siege'); continue; diff --git a/src/games/blockilization/rules/ai/navy.ts b/src/games/blockilization/rules/ai/navy.ts index 91f26e8..9fc6b23 100644 --- a/src/games/blockilization/rules/ai/navy.ts +++ b/src/games/blockilization/rules/ai/navy.ts @@ -1,4 +1,4 @@ -import { canFire, rangeOf } from '../combat'; +import { canFire, indirectFire, rangeOf } from '../combat'; import { canStand } from '../core'; import { trainable } from '../game'; import { neighbours, within } from '../hex'; @@ -154,7 +154,7 @@ export function moveNavy(ctx: Ctx, ships: Unit[], claimed: Set) { } // With an invasion: off the target city (to batter it) or off the beach (to guard the landing). if (spot === undefined && target && plan?.amphibious) { - spot = ranged ? seaSpot(ctx, u, target.tile, rangeOf(u), claimed, (t) => canFire(game, t, target.tile, rangeOf(u))) : plan.landing !== undefined ? seaSpot(ctx, u, plan.landing, 2, claimed) : undefined; + spot = ranged ? seaSpot(ctx, u, target.tile, rangeOf(u), claimed, (t) => canFire(game, t, target.tile, rangeOf(u), indirectFire(u))) : plan.landing !== undefined ? seaSpot(ctx, u, plan.landing, 2, claimed) : undefined; } // Enemies at sea near home: after them. if (spot === undefined && threats.length) { @@ -164,7 +164,7 @@ export function moveNavy(ctx: Ctx, ships: Unit[], claimed: Set) { // A war: bombard the nearest enemy city on the coast (ranged ships), the melee ones with them. if (spot === undefined && shores.length) { const city = [...shores].sort((a, b) => dist(ctx, a.tile, u.tile) - dist(ctx, b.tile, u.tile))[0]; - if (dist(ctx, city.tile, u.tile) <= 14) spot = seaSpot(ctx, u, city.tile, ranged ? rangeOf(u) : 2, claimed, (t) => !ranged || canFire(game, t, city.tile, rangeOf(u))); + if (dist(ctx, city.tile, u.tile) <= 14) spot = seaSpot(ctx, u, city.tile, ranged ? rangeOf(u) : 2, claimed, (t) => !ranged || canFire(game, t, city.tile, rangeOf(u), indirectFire(u))); } // Otherwise: on station off the bot's own city with the fewest ships about. if (spot === undefined) { diff --git a/src/games/blockilization/rules/barbarians.ts b/src/games/blockilization/rules/barbarians.ts index f1faf75..3608584 100644 --- a/src/games/blockilization/rules/barbarians.ts +++ b/src/games/blockilization/rules/barbarians.ts @@ -1,6 +1,6 @@ import { atWar, cityAt, civOf, has, note, random, spawnUnit, unitsOf } from './core'; import { service } from './citystates'; -import { canFire, canPillage, melee, pillage, ranged } from './combat'; +import { canFire, canPillage, indirectFire, melee, pillage, ranged } from './combat'; import { distance, neighbours, within } from './hex'; import { canEnter, occupants, planPath, campHooks } from './movement'; import { campCleared } from './quests'; @@ -100,7 +100,7 @@ function raid(game: Game, u: Unit, b: number) { const city = cityAt(game, t); const whose = city ? city.owner : enemy && atWar(game, b, enemy.owner) ? enemy.owner : -1; if (whose < 0 || spared(whose)) continue; - if (type.ranged && !canFire(game, u.tile, t, type.range ?? 1)) continue; + if (type.ranged && !canFire(game, u.tile, t, type.range ?? 1, indirectFire(u))) continue; if (city && !type.ranged && city.hp <= 30) continue; // they don't take cities const r = type.ranged ? ranged(game, u, t) : melee(game, u, t); if (r) return; diff --git a/src/games/blockilization/rules/combat.ts b/src/games/blockilization/rules/combat.ts index 436bb87..f35dad9 100644 --- a/src/games/blockilization/rules/combat.ts +++ b/src/games/blockilization/rules/combat.ts @@ -1,10 +1,10 @@ import { formationBonus, formationKind, sizeOf, unitName } from './armies'; import { BUILDINGS } from './buildings'; -import { atWar, canStand, cityAt, cityById, citiesOf, note, random, removeUnit, seesUnit, speed, updateVisibility } from './core'; +import { atWar, canStand, cityAt, cityById, citiesOf, inSight, note, random, removeUnit, seesUnit, speed, updateVisibility } from './core'; import { assignCitizens, blockaded, cityEffects, civEffects, garrisoned, has, happiness } from './economy'; import type { CombatWhen } from './effects'; import { cityReligion } from './faith'; -import { distance, line, neighbours, within } from './hex'; +import { distance, neighbours, ray, within } from './hex'; import { campHooks, civiliansAt, occupants } from './movement'; import { fromPromotions, PROMOTIONS, type PromotionWhen } from './promotions'; import type { City, Game, Unit } from './state'; @@ -290,19 +290,19 @@ export function gain(game: Game, u: Unit, xp: number, foe: number) { if (!barbarian && !p.cityState && p.civ !== 'barbarian') cityHooks.greatPoints(game, u, xp); } -/** Whether `from` can see `to` for a ranged attack (range, and nothing in the way unless it's next door). */ -export function canFire(game: Game, from: number, to: number, range: number): boolean { +/** + * Whether `from` can fire on `to` (Civ V's): in range, and in sight of it past what's between (next + * door always: `inSight`). A unit with Indirect Fire fires over it all, at whatever its side sees. + */ +export function canFire(game: Game, from: number, to: number, range: number, indirect = false): boolean { const d = distance(game.grid, from, to); if (d < 1 || d > range) return false; - if (d === 1) return true; - return line(game.grid, from, to) - .slice(1, -1) - .every((t) => { - const tile = game.tiles[t]; - return tile.elevation !== 'mountain' && !(tile.elevation === 'hills' || tile.feature === 'forest' || tile.feature === 'jungle') || game.tiles[from].elevation === 'hills'; - }); + return indirect || inSight(game, from, ray(game.grid, from, to)); } +/** Whether a unit fires over what's in the way: Indirect Fire (Artillery's, Rocket Artillery's and the Battleship's, or a promotion). */ +export const indirectFire = (u: { type: string; promotions?: string[] }) => !!UNITS[u.type]?.abilities?.includes('indirect_fire') || !!u.promotions?.some((p) => PROMOTIONS[p]?.indirect); + /** A unit's range (more with the Range promotion). */ export const rangeOf = (u: Unit) => (UNITS[u.type]?.range ?? 1) + fromPromotions(u, 'range'); @@ -370,7 +370,7 @@ export function melee(game: Game, u: Unit, target: number): CombatResult | null /** A ranged attack on a military unit or a city it can see: no reply, and it doesn't advance. Not from a boat. */ export function ranged(game: Game, u: Unit, target: number): CombatResult | null { const type = UNITS[u.type]; - if (!type.ranged || u.embarked || !canFire(game, u.tile, target, rangeOf(u))) return null; + if (!type.ranged || u.embarked || !canFire(game, u.tile, target, rangeOf(u), indirectFire(u))) return null; if (type.abilities?.includes('must_setup') && !u.setUp) return null; const p = game.players[u.owner]; if (!(p.visible[target] > 0)) return null; diff --git a/src/games/blockilization/rules/core.ts b/src/games/blockilization/rules/core.ts index 1b1a238..2ddf651 100644 --- a/src/games/blockilization/rules/core.ts +++ b/src/games/blockilization/rules/core.ts @@ -2,7 +2,7 @@ import { BUILDINGS } from './buildings'; import { CITY_STATES, CIVS, unitFor } from './civs'; import { handicapEffects, handicapOf } from './difficulty'; import type { Effects } from './effects'; -import { distance, line, neighbours, within, type Grid } from './hex'; +import { clear, distance, neighbours, ray, within, type Grid, type Ray } from './hex'; import { generate, placeVolcanoes, Rng } from './mapgen'; import { FIRST_FINDER_GOLDEN, FOUND_HAPPINESS, NATURAL_WONDERS, wondersBeside } from './nature'; import { policyEffects } from './policies'; @@ -10,7 +10,7 @@ import { fromPromotions } from './promotions'; import { beliefEffects } from './religion'; import { earthSize, MAP_SIZES, SPEEDS, type City, type Game, type Note, type Player, type Settings, type Unit } from './state'; import { bestUnit, eraStartOf, scenarioOf, startEraOf, startTechs } from './starts'; -import { RESOURCES, TERRAINS } from './terrain'; +import { RESOURCES, seeFromLevel, seeThroughLevel, TERRAINS } from './terrain'; import { bindTeams, teammates, teamOf } from './teams'; import { baseTechCost, ERA_NAMES, ERAS, TECHS } from './techs'; import { UNITS } from './units'; @@ -309,36 +309,34 @@ export function removeUnit(game: Game, u: Unit) { // Fog of war // ------------------------------------------------------------------------------------------------- -/** What blocks sight: mountains, and hills, forests and jungles (which are seen, but not past). */ -const blocks = (game: Game, t: number) => { - const tile = game.tiles[t]; - return tile.elevation === 'mountain' || tile.elevation === 'hills' || tile.feature === 'forest' || tile.feature === 'jungle'; -}; - /** - * The tiles within `radius` of a tile, each with those between them on the line of sight: the grid's - * alone (a map of that size always has the same), so worked out once for each tile and radius. + * The tiles within `radius` of a tile, each with the way a line of sight takes to it: the grid's alone + * (a map of that size always has the same), so worked out once for each tile and radius. */ -const viewLines = new Map>(); -function linesOfSight(g: Grid, from: number, radius: number): { t: number; between: number[] }[] { +const viewLines = new Map>(); +function linesOfSight(g: Grid, from: number, radius: number): { t: number; way: Ray }[] { const size = `${g.width}x${g.height}${g.wrap ? 'w' : ''}`; - const lines = viewLines.get(size) ?? new Map(); + const lines = viewLines.get(size) ?? new Map(); viewLines.set(size, lines); const key = from * 16 + radius; let out = lines.get(key); if (!out) { - out = within(g, from, radius).map((t) => ({ t, between: distance(g, from, t) <= 1 ? [] : line(g, from, t).slice(1, -1) })); + out = within(g, from, radius).map((t) => ({ t, way: ray(g, from, t) })); lines.set(key, out); } return out; } -/** The tiles a unit or city on `from` sees within `radius` (higher ground sees over what blocks). */ +/** Whether `from` sees the end of a ray from it past what's between (`hex.ts`'s `clear`): only what stands higher than `from` blocks (`seeThroughLevel`). */ +export function inSight(game: Game, from: number, way: Ray): boolean { + const eye = seeFromLevel(game.tiles[from]); + return clear(way, (x) => seeThroughLevel(game.tiles[x]) > eye); +} + +/** The tiles a unit or city on `from` sees within `radius` (from higher ground, over more of what blocks). */ export function sight(game: Game, from: number, radius: number): number[] { - const high = game.tiles[from].elevation === 'hills'; const out: number[] = []; - // Seen unless something between blocks the view (from a hill, only mountains do). - for (const { t, between } of linesOfSight(game.grid, from, radius)) if (between.every((x) => (high ? game.tiles[x].elevation !== 'mountain' : !blocks(game, x)))) out.push(t); + for (const { t, way } of linesOfSight(game.grid, from, radius)) if (inSight(game, from, way)) out.push(t); return out; } diff --git a/src/games/blockilization/rules/game.ts b/src/games/blockilization/rules/game.ts index 2d3b1ee..160a854 100644 --- a/src/games/blockilization/rules/game.ts +++ b/src/games/blockilization/rules/game.ts @@ -150,7 +150,7 @@ function attackWhy(game: Game, u: Unit, target: number): string { if (UNITS[u.type].ranged) { if (u.embarked) return "It can't fire from the water"; const range = rangeOf(u); - return distance(game.grid, u.tile, target) > range ? `Out of range: it fires up to ${range} tile${range === 1 ? '' : 's'} away` : 'It has no clear line to it: hills, forest, jungle or mountains in the way (from a hill it sees over them)'; + return distance(game.grid, u.tile, target) > range ? `Out of range: it fires up to ${range} tile${range === 1 ? '' : 's'} away` : 'It has no clear line to it: hills, forest, jungle or mountains in the way (from a hill it sees over forest and hills, not wooded hills or mountains)'; } return neighbours(game.grid, u.tile).includes(target) ? "It can't attack that" : 'It fights hand to hand: move it next to the enemy first'; } diff --git a/src/games/blockilization/rules/hex.ts b/src/games/blockilization/rules/hex.ts index a0e1478..b199c31 100644 --- a/src/games/blockilization/rules/hex.ts +++ b/src/games/blockilization/rules/hex.ts @@ -154,6 +154,109 @@ export function line(g: Grid, a: number, b: number): number[] { return out; } +/** + * A shot's way from one tile to another, as Civ V aims one (its targeting ray, `CvTargeting`): a line + * stepped across the grid's axial coordinates, a neighbouring tile at a time, so it's as long as the + * distance. `tiles` are those it enters in turn (the last is where it ends); `grazed`, for each, the + * tile it runs beside on the way in, round the other side of the one before (the way it could have + * gone instead): -1 off the map, none on its first step or along one of the grid's six straight lines. + */ +export interface Ray { + tiles: number[]; + grazed: (number | undefined)[]; +} + +export function ray(g: Grid, a: number, b: number): Ray { + const [aq, ar] = cube(g, a); + let [bq, br] = cube(g, b); + // (The copy of b round the world nearest a, as `line` takes it.) + if (g.wrap) { + const apart = bq - aq + (br - ar) / 2; + bq += apart > g.width / 2 ? -g.width : apart < -g.width / 2 ? g.width : 0; + } + const dq = bq - aq; + const dr = br - ar; + const sq = dq < 0 ? -1 : 1; + const sr = dr < 0 ? -1 : 1; + // Bresenham's line, in doubled steps: along the longer axis, a step on the shorter now and then. + // Where both axes grow the same way, a step on both isn't to a neighbour: it's made one then the other. + const ex = Math.abs(dq) * 2; + const ey = Math.abs(dr) * 2; + const diagonal = sq !== sr; + const straight = dq === 0 || dr === 0; + const out: Ray = { tiles: [], grazed: [] }; + let q = 0; + let r = 0; + let lastQ = 0; + let lastR = 0; + let stepQ = 0; + let stepR = 0; + // Enters the tile it's come to (true: the end). + const enter = () => { + out.grazed.push(straight || !out.tiles.length ? undefined : fromCube(g, aq + q - stepQ, ar + r - stepR)); + out.tiles.push(fromCube(g, aq + q, ar + r)); + stepQ = q - lastQ; + stepR = r - lastR; + lastQ = q; + lastR = r; + return q === dq && r === dr; + }; + const alongQ = ex > ey; + const long = alongQ ? ex : ey; + const short = alongQ ? ey : ex; + // (Civ V starts it lower for a line two rows to each column across, so that its first step leans + // the better way.) + let f = alongQ ? ex >> 1 : ey !== ex && ex !== 0 && ey >> 1 === ex ? (ey >> 1) - ex : ey >> 1; + while (alongQ ? q !== dq : r !== dr) { + f += short; + if (f >= long) { + f -= long; + if (diagonal) { + q += sq; + r += sr; + } else { + q += sq; + if (enter()) break; + r += sr; + } + } else if (alongQ) q += sq; + else r += sr; + if (enter()) break; + } + return out; +} + +/** + * Whether a ray gets through, `blocks` saying which tiles stop it (and one off the map does). Civ V's: + * within two tiles, a tile in the way can be looked round when the line passes by its side and the + * tile beside is clear; further, every tile it crosses must be clear, and of those it runs beside, one + * may block in its first three steps, no more, and none after. + */ +export function clear(path: Ray, blocks: (t: number) => boolean): boolean { + const stops = (t: number) => t < 0 || blocks(t); + const n = path.tiles.length; + const strict = n > 2; + let grazed = 0; + let behind = false; + for (let i = 0; i < n; i++) { + const side = path.grazed[i]; + if (strict) { + if (side !== undefined && stops(side) && (i > 2 || ++grazed >= 2)) return false; + } else if (behind) { + // Behind the last tile: round it by the one beside (none on a straight line). + if (side === undefined || stops(side)) return false; + behind = false; + } + // (The end itself never stops it: a tile that blocks is seen, and shot at, but not past.) + if (i === n - 1) break; + if (stops(path.tiles[i])) { + if (strict) return false; + behind = true; + } + } + return true; +} + /** The nearest whole cube coordinates. */ export function roundCube(q: number, r: number, s: number): [number, number, number] { let rq = Math.round(q); diff --git a/src/games/blockilization/rules/preview.ts b/src/games/blockilization/rules/preview.ts index aa2fe72..1ac957d 100644 --- a/src/games/blockilization/rules/preview.ts +++ b/src/games/blockilization/rules/preview.ts @@ -1,6 +1,6 @@ import { unitName } from './armies'; import { airTarget, isAircraft } from './air'; -import { canFire, cityFireStrength, cityMaxHp, cityStrength, damageFor, rangeOf, strength, type Modifier } from './combat'; +import { canFire, cityFireStrength, cityMaxHp, cityStrength, damageFor, indirectFire, rangeOf, strength, type Modifier } from './combat'; import { atWar, cityAt } from './core'; import { distance, neighbours } from './hex'; import { occupants } from './movement'; @@ -107,7 +107,7 @@ function previewFight(game: Game, u: Unit, target: number): CombatPreview | null // (Units with a ranged attack always fire, next door too, as the attack order has them: mounted archers as well.) const fires = !!type.ranged; if (fires) { - if (!(isAircraft(u) ? airTarget(game, u, target) : canFire(game, u.tile, target, rangeOf(u)))) return null; + if (!(isAircraft(u) ? airTarget(game, u, target) : canFire(game, u.tile, target, rangeOf(u), indirectFire(u)))) return null; // (A siege unit not set up sets up as it fires, with two moves left: one to set up, one to fire.) const blocked = type.abilities?.includes('must_setup') && !u.setUp && u.moves < 2 ? 'Too few moves to set up and fire: set it up now, it fires next turn' : undefined; const why: Modifier[] = []; diff --git a/src/games/blockilization/rules/promotions.ts b/src/games/blockilization/rules/promotions.ts index 30f006f..7651d3d 100644 --- a/src/games/blockilization/rules/promotions.ts +++ b/src/games/blockilization/rules/promotions.ts @@ -5,11 +5,11 @@ import { UNITS, type UnitClass } from './units'; * Promotions: a unit earns experience fighting, and at each step (10, 30, 60, 100, 150, 210, then * more) it can take one, each class of unit from its own list, some needing another first. Civ V's, * the common ones: Shock and Drill (open ground, rough ground), Cover, Accuracy and Barrage for archers - * and siege, March and Medic, Blitz, Logistics and Range, Formation against horses, Siege against - * cities, Sentry for sight, a couple for ships, and aircraft's own (Interception, Air Siege, Bombardment, - * Air Targeting, Evasion, Air Repair, Air Range). What each does is data (`combat`, `range`...), - * which the combat, movement and healing rules read; a few are only given (Morale, the Heroic Epic's; - * Altitude Training, the Cloudcrown's). + * and siege, March and Medic, Blitz, Logistics, Range and Indirect Fire, Formation against horses, + * Siege against cities, Sentry for sight, a couple for ships, and aircraft's own (Interception, Air + * Siege, Bombardment, Air Targeting, Evasion, Air Repair, Air Range). What each does is data + * (`combat`, `range`...), which the combat, movement and healing rules read; a few are only given + * (Morale, the Heroic Epic's; Altitude Training, the Cloudcrown's). */ /** When a promotion's combat bonus counts. */ @@ -36,7 +36,9 @@ export interface Promotion { text: string; /** The classes that can take it (none: only given, never chosen). */ classes: UnitClass[]; + /** Promotions it needs first: all of `needs`, and one of `needsOne`. */ needs?: string[]; + needsOne?: string[]; /** Combat strength percent, and when it counts. */ combat?: { percent: number; when: PromotionWhen }; /** More range, sight and moves; attacks a turn; less damage from interception, in percent (an aircraft's Evasion). */ @@ -45,6 +47,8 @@ export interface Promotion { moves?: number; attacks?: number; evasion?: number; + /** Fires over what's in the way of its shot (Indirect Fire). */ + indirect?: boolean; /** Heals even after moving or fighting (March). */ healsOnTheMove?: boolean; /** Units next to it heal this much more (Medic). */ @@ -79,6 +83,7 @@ export const PROMOTION_LIST: Promotion[] = [ { id: 'barrage_3', name: 'Barrage III', text: '+15% more in rough terrain.', classes: RANGED, needs: ['barrage_2'], combat: { percent: 15, when: 'ranged_rough' } }, { id: 'logistics', name: 'Logistics', text: 'Can fire twice a turn.', classes: RANGED, needs: ['accuracy_3'], attacks: 1 }, { id: 'range', name: 'Range', text: '+1 range.', classes: RANGED, needs: ['barrage_2'], range: 1 }, + { id: 'indirect_fire', name: 'Indirect Fire', text: 'Fires over hills, forest, jungle and mountains in the way, at anything your units see.', classes: RANGED, needsOne: ['accuracy_3', 'barrage_3'], indirect: true }, { id: 'siege', name: 'Siege', text: '+50% against cities.', classes: ['melee', 'gun', 'ranged', 'siege', 'armor'], combat: { percent: 50, when: 'vs_cities' } }, { id: 'sentry', name: 'Sentry', text: '+1 sight.', classes: [...MELEE, ...RANGED, ...NAVAL], sight: 1 }, { id: 'mobility', name: 'Mobility', text: '+1 move.', classes: ['mounted', 'armor', 'recon', ...NAVAL], needs: ['shock_2'], moves: 1 }, @@ -119,7 +124,10 @@ export function promotionsDue(u: Unit): number { export function promotionsFor(u: Unit): Promotion[] { const cls = UNITS[u.type]?.class; if (!cls) return []; - return PROMOTION_LIST.filter((p) => p.classes.includes(cls) && !u.promotions.includes(p.id) && (p.needs ?? []).every((n) => u.promotions.includes(n))); + const has = (id: string) => u.promotions.includes(id); + // (Indirect Fire isn't for those that have it already: Artillery, Rocket Artillery, the Battleship.) + const indirect = !!UNITS[u.type].abilities?.includes('indirect_fire'); + return PROMOTION_LIST.filter((p) => p.classes.includes(cls) && !has(p.id) && (p.needs ?? []).every(has) && (!p.needsOne || p.needsOne.some(has)) && !(p.indirect && indirect)); } /** The sum of a number its promotions add (range, sight, moves, attacks, evasion). */ diff --git a/src/games/blockilization/rules/terrain.ts b/src/games/blockilization/rules/terrain.ts index ac9de26..1b14033 100644 --- a/src/games/blockilization/rules/terrain.ts +++ b/src/games/blockilization/rules/terrain.ts @@ -73,6 +73,15 @@ export const FEATURES: Record = { fallout: { name: 'Fallout', yields: { food: -3, prod: -3, gold: -10 }, move: 2, defense: 0, hurts: 15 }, }; +/** + * Sight's levels (Civ V's): how high a tile stands to see from, hills a level up and mountains two; + * and to see past, forest and jungle a level higher still. Only what stands higher than the eye + * blocks it, so from flat land forest, jungle, hills and mountains do (they're seen, not what's behind + * them); from a hill, only wooded hills and mountains. + */ +export const seeFromLevel = (tile: { elevation: Elevation }) => (tile.elevation === 'mountain' ? 2 : tile.elevation === 'hills' ? 1 : 0); +export const seeThroughLevel = (tile: { elevation: Elevation; feature?: string }) => seeFromLevel(tile) + (tile.feature === 'forest' || tile.feature === 'jungle' ? 1 : 0); + /** * Nuclear fallout (Civ V's): each land tile of a blast (not a city's, a mountain or a natural * wonder's) gets it by this chance, the blast's forests, jungles and marshes burnt away whether or diff --git a/src/games/blockilization/rules/units.ts b/src/games/blockilization/rules/units.ts index 762fd69..5b92f9c 100644 --- a/src/games/blockilization/rules/units.ts +++ b/src/games/blockilization/rules/units.ts @@ -38,6 +38,8 @@ export type Ability = | 'bonus_vs_cities' | 'penalty_vs_cities' | 'must_setup' + /** Fires over what's in the way of its shot, at anything its side sees (Civ V's Indirect Fire). */ + | 'indirect_fire' | 'no_attack' | 'coast_only' | 'see_submarines' @@ -158,7 +160,7 @@ export const UNIT_LIST: UnitType[] = [ U({ id: 'rifleman', name: 'Rifleman', class: 'gun', cost: 225, strength: 34, moves: 2, tech: 'rifling', obsolete: 'replaceable_parts', upgrade: 'great_war_infantry' }), U({ id: 'cavalry', name: 'Cavalry', class: 'mounted', cost: 225, strength: 34, moves: 4, tech: 'military_science', obsolete: 'combustion', upgrade: 'landship', resource: 'horses', abilities: ['no_terrain_defense', 'penalty_vs_cities', 'withdraw'] }), U({ id: 'gatling_gun', name: 'Gatling Gun', class: 'ranged', cost: 225, strength: 30, ranged: 30, range: 1, moves: 2, tech: 'industrialization', obsolete: 'ballistics', upgrade: 'machine_gun' }), - U({ id: 'artillery', name: 'Artillery', class: 'siege', cost: 250, strength: 21, ranged: 28, range: 3, moves: 2, tech: 'dynamite', obsolete: 'rocketry', upgrade: 'rocket_artillery', abilities: ['bonus_vs_cities', 'must_setup'] }), + U({ id: 'artillery', name: 'Artillery', class: 'siege', cost: 250, strength: 21, ranged: 28, range: 3, moves: 2, tech: 'dynamite', obsolete: 'rocketry', upgrade: 'rocket_artillery', abilities: ['bonus_vs_cities', 'must_setup', 'indirect_fire'] }), U({ id: 'ironclad', name: 'Ironclad', class: 'naval_melee', domain: 'sea', cost: 250, strength: 45, moves: 4, tech: 'steam_power', obsolete: 'combustion', upgrade: 'destroyer', resource: 'coal', abilities: ['coast_only'] }), U({ id: 'great_war_infantry', name: 'Great War Infantry', class: 'gun', cost: 320, strength: 50, moves: 2, tech: 'replaceable_parts', obsolete: 'plastics', upgrade: 'infantry' }), U({ id: 'triplane', name: 'Triplane', class: 'fighter', domain: 'air', cost: 325, strength: 0, ranged: 35, range: 5, moves: 1, tech: 'flight', obsolete: 'radar', upgrade: 'fighter', resource: 'oil' }), @@ -169,7 +171,7 @@ export const UNIT_LIST: UnitType[] = [ U({ id: 'anti_aircraft_gun', name: 'Anti-Aircraft Gun', class: 'gun', cost: 300, strength: 50, moves: 2, tech: 'ballistics', obsolete: 'rocketry', upgrade: 'mobile_sam', abilities: ['intercept'] }), U({ id: 'landship', name: 'Landship', class: 'armor', cost: 350, strength: 60, moves: 4, tech: 'combustion', obsolete: 'combined_arms', upgrade: 'tank', resource: 'oil', abilities: ['no_terrain_defense', 'penalty_vs_cities'] }), U({ id: 'destroyer', name: 'Destroyer', class: 'naval_melee', domain: 'sea', cost: 375, strength: 55, moves: 6, tech: 'combustion', abilities: ['see_submarines'] }), - U({ id: 'battleship', name: 'Battleship', class: 'naval_ranged', domain: 'sea', cost: 375, strength: 55, ranged: 65, range: 3, moves: 5, tech: 'electronics', resource: 'oil' }), + U({ id: 'battleship', name: 'Battleship', class: 'naval_ranged', domain: 'sea', cost: 375, strength: 55, ranged: 65, range: 3, moves: 5, tech: 'electronics', resource: 'oil', abilities: ['indirect_fire'] }), U({ id: 'submarine', name: 'Submarine', class: 'submarine', domain: 'sea', cost: 325, strength: 35, ranged: 60, range: 1, moves: 5, tech: 'refrigeration', obsolete: 'telecommunications', upgrade: 'nuclear_submarine', abilities: ['invisible'] }), U({ id: 'carrier', name: 'Carrier', class: 'carrier', domain: 'sea', cost: 375, strength: 40, moves: 5, tech: 'electronics' }), U({ id: 'anti_tank_gun', name: 'Anti-Tank Gun', class: 'gun', cost: 300, strength: 50, moves: 2, tech: 'combined_arms', obsolete: 'computers', upgrade: 'helicopter_gunship' }), @@ -182,7 +184,7 @@ export const UNIT_LIST: UnitType[] = [ U({ id: 'mobile_sam', name: 'Mobile SAM', class: 'gun', cost: 425, strength: 65, moves: 4, tech: 'rocketry', abilities: ['intercept'] }), U({ id: 'atomic_bomb', name: 'Atomic Bomb', class: 'missile', domain: 'air', cost: 600, strength: 0, ranged: 0, range: 10, moves: 1, tech: 'nuclear_fission', resource: 'uranium', abilities: ['nuke'], blast: 1 }), U({ id: 'guided_missile', name: 'Guided Missile', class: 'missile', domain: 'air', cost: 150, strength: 0, ranged: 60, range: 8, moves: 1, tech: 'advanced_ballistics' }), - U({ id: 'rocket_artillery', name: 'Rocket Artillery', class: 'siege', cost: 425, strength: 45, ranged: 60, range: 3, moves: 3, tech: 'rocketry', resource: 'aluminum', abilities: ['bonus_vs_cities'] }), + U({ id: 'rocket_artillery', name: 'Rocket Artillery', class: 'siege', cost: 425, strength: 45, ranged: 60, range: 3, moves: 3, tech: 'rocketry', resource: 'aluminum', abilities: ['bonus_vs_cities', 'indirect_fire'] }), // ----- Information U({ id: 'helicopter_gunship', name: 'Helicopter Gunship', class: 'helicopter', cost: 425, strength: 60, moves: 6, tech: 'computers', resource: 'aluminum', abilities: ['ignore_terrain'] }), U({ id: 'mechanized_infantry', name: 'Mechanized Infantry', class: 'gun', cost: 375, strength: 90, moves: 3, tech: 'mobile_tactics' }), diff --git a/src/games/blockilization/tests/cases.ts b/src/games/blockilization/tests/cases.ts index e85631b..b5ebe0b 100644 --- a/src/games/blockilization/tests/cases.ts +++ b/src/games/blockilization/tests/cases.ts @@ -4,7 +4,7 @@ import { cityHeal } from '../rules/combat'; import { routeBlockaded } from '../rules/trade'; import { adviseResearch, adviseWork } from '../advisor'; import { BUILDINGS } from '../rules/buildings'; -import { canPillage, cityFireStrength, cityHooks, cityMaxHp, cityStrength, damageFor, gain, leadership, rangeOf, strength } from '../rules/combat'; +import { canFire, canPillage, cityFireStrength, cityHooks, cityMaxHp, cityStrength, damageFor, gain, indirectFire, leadership, rangeOf, strength } from '../rules/combat'; import { CITY_STATES, CIV_LIST } from '../rules/civs'; import { PERSONALITIES } from '../rules/ai/personality'; import { UNITS } from '../rules/units'; @@ -16,7 +16,7 @@ import { ANARCHY_TURNS, apply, applyOnce, blockers, canBuild, workTurns, constru import { colOf, distance, line, neighbour, neighbours, rowOf, tileAt, within } from '../rules/hex'; import { generate, Rng, START_ISLAND } from '../rules/mapgen'; import { NATURAL_WONDERS } from '../rules/nature'; -import { IMPROVEMENTS, TERRAINS, type ImprovementId } from '../rules/terrain'; +import { IMPROVEMENTS, TERRAINS, type Elevation, type FeatureId, type ImprovementId } from '../rules/terrain'; import { makeCtx, militaryAt } from '../rules/ai/context'; import { opinionOf } from '../rules/ai/opinion'; import { planWar } from '../rules/ai/strategy'; @@ -815,6 +815,115 @@ test('a siege unit sets up as it fires when it has the moves for both (Civ V’s ok(!!t.setUp && d.hp < 100, 'set up, and hit it'); }); +test('line of sight (Civ V’s): forest, jungle, hills and mountains block a shot past them, not at them; next door always; from a hill, only wooded hills and mountains', () => { + const { game, at } = blank(); + const from = at(10, 8); + const between = at(11, 8); + const past = at(12, 8); + const set = (t: number, elevation: Elevation, feature?: FeatureId) => Object.assign(game.tiles[t], { elevation, feature }); + ok(canFire(game, from, past, 2), 'over open ground'); + for (const [elevation, feature] of [['flat', 'forest'], ['flat', 'jungle'], ['hills', undefined], ['mountain', undefined]] as const) { + set(between, elevation, feature); + ok(!canFire(game, from, past, 2), `not past ${feature ?? elevation}`); + ok(canFire(game, from, between, 2), `at ${feature ?? elevation} next door`); + } + set(between, 'flat', 'marsh'); + ok(canFire(game, from, past, 2), 'past marsh'); + set(between, 'flat'); + set(past, 'hills', 'forest'); + ok(canFire(game, from, past, 2), 'at a wooded hill, nothing between'); + set(past, 'flat'); + set(from, 'flat', 'forest'); + set(between, 'flat', 'forest'); + ok(!canFire(game, from, past, 2), 'standing in a forest is no help'); + set(from, 'hills'); + for (const [elevation, feature, sees] of [['flat', 'forest', true], ['flat', 'jungle', true], ['hills', undefined, true], ['hills', 'forest', false], ['hills', 'jungle', false], ['mountain', undefined, false]] as const) { + set(between, elevation, feature); + eq(canFire(game, from, past, 2), sees, `from a hill, past ${feature ? `${feature} on ` : ''}${elevation}`); + } + // Sight goes the same way. + set(from, 'flat'); + set(between, 'flat', 'forest'); + unit(game, 'warrior', 0, from); + const sees = (t: number) => game.players[0].visible[t] > 0; + ok(sees(between) && !sees(past), 'it sees the forest, not past it'); + set(from, 'hills'); + updateAllVisibility(game); + ok(sees(past), 'from a hill, past it'); + set(between, 'hills', 'forest'); + updateAllVisibility(game); + ok(!sees(past), 'not past a wooded hill'); +}); + +test('line of sight: two tiles off, a shot passing between two tiles is stopped only when both block; further, by any it crosses, or two it runs beside', () => { + const { game, at } = blank(); + const from = at(10, 8); + const off = at(11, 9); + const wood = (...ts: number[]) => { + for (const t of game.tiles) delete t.feature; + for (const t of ts) game.tiles[t].feature = 'forest'; + }; + const sides = neighbours(game.grid, from).filter((t) => neighbours(game.grid, off).includes(t)); + eq(sides.length, 2, 'two tiles between'); + for (const t of sides) { + wood(t); + ok(canFire(game, from, off, 2), 'round the other side of one'); + } + wood(...sides); + ok(!canFire(game, from, off, 2), 'not between two'); + // Looking round it, a unit sees there too. + wood(sides[0]); + unit(game, 'warrior', 0, from); + ok(game.players[0].visible[off] > 0, 'and sees it'); + // Three off (a longbowman's range): it crosses (11, 8) and (11, 9), and runs beside (10, 9) and (12, 8). + const far = at(12, 9); + for (const t of [at(11, 8), at(11, 9)]) { + wood(t); + ok(!canFire(game, from, far, 3), 'three off, not past a tile it crosses'); + } + for (const t of [at(10, 9), at(12, 8)]) { + wood(t); + ok(canFire(game, from, far, 3), 'one beside it is no matter'); + } + wood(at(10, 9), at(12, 8)); + ok(!canFire(game, from, far, 3), 'two beside it block it'); + wood(at(12, 8)); + ok(!canFire(game, from, at(13, 8), 3), 'nor round one on a straight line'); +}); + +test('Indirect Fire: Artillery, Rocket Artillery and the Battleship fire over what blocks, at what their side sees; ranged units earn it after Accuracy III or Barrage III; cities fire over it all', () => { + const { game, at } = blank(); + war(game, 0, 1); + game.tiles[at(11, 8)].feature = 'forest'; + const gun = unit(game, 'artillery', 0, at(10, 8), { moves: 2 }); + const foe = unit(game, 'warrior', 1, at(12, 8)); + ok(!apply(game, 0, { t: 'attack', unit: gun.id, target: foe.tile }).ok, 'not at what its side can’t see'); + unit(game, 'scout', 0, at(13, 8)); + ok(apply(game, 0, { t: 'attack', unit: gun.id, target: foe.tile }).ok, 'over the forest, at what a scout sees'); + ok(foe.hp < 100, 'and hits it'); + for (const [type, has] of [['artillery', true], ['rocket_artillery', true], ['battleship', true], ['cannon', false], ['frigate', false], ['missile_cruiser', false], ['crossbowman', false]] as const) eq(indirectFire({ ...gun, type }), has, type); + ok(!canFire(game, at(10, 8), foe.tile, 2, indirectFire({ ...gun, type: 'cannon' })), 'a cannon can’t'); + // The promotion. + const bow = unit(game, 'crossbowman', 0, at(4, 4), { xp: 200 }); + const offered = () => promotionsFor(bow).some((p) => p.id === 'indirect_fire'); + bow.promotions = ['accuracy_1', 'accuracy_2']; + ok(!offered(), 'not before Accuracy III'); + bow.promotions.push('accuracy_3'); + ok(offered(), 'after Accuracy III'); + bow.promotions = ['barrage_1', 'barrage_2', 'barrage_3']; + ok(offered(), 'or Barrage III'); + ok(apply(game, 0, { t: 'promote', unit: bow.id, promotion: 'indirect_fire' }).ok, 'taken'); + ok(indirectFire(bow), 'it fires over what’s in the way'); + gun.promotions = ['accuracy_1', 'accuracy_2', 'accuracy_3']; + ok(!promotionsFor(gun).some((p) => p.id === 'indirect_fire'), 'not offered to Artillery, which has it'); + // A city fires over forest at whatever its side sees. + const c = city(game, 0, at(10, 14)); + game.tiles[at(11, 14)].feature = 'forest'; + const raider = unit(game, 'warrior', 1, at(12, 14)); + unit(game, 'scout', 0, at(13, 14)); + ok(apply(game, 0, { t: 'cityFire', city: c.id, target: raider.tile }).ok, 'the city fires past the forest'); +}); + test('a soldier stepping onto an enemy’s civilian takes it; at peace it can’t step there', () => { const { game, at } = blank(); war(game, 0, 1);