Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/games/blockilization/client/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { canFortify, isMilitary, UNITS } from '../rules/units';
import type { CityWire, PreviewAsk, UnitWire } from '../protocol';
import { cam, centreHere, pick, zoom } from './camera';
import { flagsIn, hitAt, type Hit } from './overlay';
import { ask, attackable, cityAt, enemyAt, me, myCity, myUnit, store, unitsAt } from './store';
import { ask, attackable, cityAt, enemyAt, isNuke, me, myCity, myUnit, store, unitsAt } from './store';
import { closePanels, endTurnAction, explores, front, openCity, platformMenuOpen, selectNextIdle, send, setTipPosition, toggleLens, togglePanel } from './ui';
import { openPedia, tileArticle } from './pedia';
import { openOverview } from './overview';
Expand Down Expand Up @@ -90,6 +90,9 @@ function talkAt(city: CityWire): boolean {
return true;
}

/** Whether a unit is an aircraft of yours that could rebase to a tile now (as its owner's view lists: its cities and ships with room, in reach). */
const rebasesTo = (u: UnitWire | undefined, t: number) => !!u && t !== u.tile && !!u.bases?.includes(t);

/** A click on the map (or a tap, `touch`): on a unit's flag or a city's banner (`hit`), else on the tile under it. */
function act(t: number, right: boolean, hit: Hit | null = null, touch = false, shift = false) {
const v = store.view;
Expand Down Expand Up @@ -130,15 +133,17 @@ function act(t: number, right: boolean, hit: Hit | null = null, touch = false, s
store.changed++;
return;
}
if (!right && city?.owner === me()) return openCity(city.id);
// (Your city's banner opens it: unless an aircraft of yours is selected that could rebase there, which goes.)
if (!right && city?.owner === me() && !rebasesTo(myUnit(store.selectedUnit), t)) return openCity(city.id);
// (Another's banner: its city-state or its leader, unless a unit of yours is about to be sent there.)
if (!right && city && !myUnit(store.selectedUnit) && talkAt(city)) return;
}
if (t < 0) return;
const sel = myUnit(store.selectedUnit);
const mineHere = unitsAt(t).filter((u) => u.owner === me());
const city = cityAt(t);
if (!right) {
// (An aircraft selected, a click on where it could rebase: it goes there, not selecting what's there.)
if (!right && !rebasesTo(sel, t)) {
// Selecting: your own units here (each click the next), else your city.
if (sel && sel.tile === t && mineHere.length > 1) {
const i = mineHere.findIndex((u) => u.id === sel.id);
Expand Down Expand Up @@ -194,8 +199,13 @@ function act(t: number, right: boolean, hit: Hit | null = null, touch = false, s
store.changed++;
return;
}
if (attackable(sel, t)) send({ t: 'attack', unit: sel.id, target: t });
else if (UNITS[sel.type]?.domain === 'air') send({ t: 'rebase', unit: sel.id, to: t });
if (attackable(sel, t)) {
// (A nuclear strike can't be taken back, and the world won't forget it: asked first.)
if (isNuke(sel)) {
const unit = sel.id;
ask(`Launch the ${UNITS[sel.type].name} here? Everything in the blast is struck, its land left in fallout, and every civ that knows you will long remember it. It can only fall where everyone it would hit is someone you're at war with.`, 'Launch it', () => send({ t: 'attack', unit, target: t }));
} else send({ t: 'attack', unit: sel.id, target: t });
} else if (UNITS[sel.type]?.domain === 'air') send({ t: 'rebase', unit: sel.id, to: t });
else {
// A melee unit sent at an enemy it can reach this turn charges: next to it, then the attack.
const from = chargeFrom(sel, t);
Expand Down
22 changes: 19 additions & 3 deletions src/games/blockilization/client/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,8 +576,22 @@ export function drawOverlay(now: number) {
const committed = routeOf(sel);
if (committed) drawPath(sel.tile, committed, 'committed', now);
const goal = committed?.tiles[committed.tiles.length - 1];
// An aircraft doesn't go by a way: where it could rebase, outlined (as a rebase's aim has it, below).
const air = UNITS[sel.type]?.domain === 'air';
if (air && !store.unitAim) {
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(106,184,255,0.6)';
for (const t of sel.bases ?? []) if (hexPath(t, tileY(t), 0.9)) ctx.stroke();
}
if (hover >= 0 && hover !== sel.tile && attackable(sel, hover)) drawTarget(hover);
else if (hover >= 0 && hover !== sel.tile && hover !== goal) {
else if (air) {
// (Nowhere it could rebase: no way there.)
if (hover >= 0 && hover !== sel.tile && known[hover] && !sel.bases?.includes(hover)) {
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255,107,94,0.85)';
if (hexPath(hover, tileY(hover), 0.9)) ctx.stroke();
}
} else if (hover >= 0 && hover !== sel.tile && hover !== goal) {
const preview = previewPath(sel, hover);
if (preview) drawPath(sel.tile, preview, 'preview', now);
else if (known[hover]) {
Expand Down Expand Up @@ -613,12 +627,14 @@ export function drawOverlay(now: number) {
if (store.hover >= 0 && store.hover !== aiming.tile && enemyAt(store.hover)) drawTarget(store.hover);
}
// A unit choosing where to go by air: where it can (a paratrooper, empty land it knows within its
// drop's reach; an airlift, your other cities with an airport), outlined.
// drop's reach; an airlift, your other cities with an airport; an aircraft, its bases in reach), outlined.
const flier = store.unitAim ? view.units.find((u) => u.id === store.unitAim!.unit) : undefined;
if (flier && store.unitAim) {
const places = store.unitAim.order === 'paradrop'
? within(g, flier.tile, PARADROP_RANGE).filter((t) => t !== flier.tile && known[t] && !TERRAINS[store.terrain[t]?.terrain]?.water && store.terrain[t]?.elevation !== 'mountain' && !view.units.some((u) => u.tile === t) && !cityAt(t))
: view.cities.filter((c) => c.owner === me() && c.tile !== flier.tile && c.buildings?.includes('airport')).map((c) => c.tile);
: store.unitAim.order === 'rebase'
? (flier.bases ?? [])
: view.cities.filter((c) => c.owner === me() && c.tile !== flier.tile && c.buildings?.includes('airport')).map((c) => c.tile);
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(106,184,255,0.75)';
ctx.fillStyle = 'rgba(106,184,255,0.14)';
Expand Down
7 changes: 5 additions & 2 deletions src/games/blockilization/client/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,18 +180,21 @@ const STATUS: Record<string, [string, string]> = {
fortified: ['Fortified', '#7bd16a'],
sleeping: ['Asleep', '#8fa1b3'],
alert: ['On alert', '#7bd16a'],
patrol: ['On patrol', '#7bd16a'],
skipped: ['Waiting this turn', '#8fa1b3'],
done: ['Done this turn', '#5f7286'],
};

function statusOf(u: UnitWire): string {
if ((u.promotionsDue ?? 0) > 0) return 'promote';
// (Due, and one open to it to take: as the unit's own card has it.)
if ((u.promotionsDue ?? 0) > 0 && u.promotionOptions?.length) return 'promote';
if (u.work) return 'working';
if (u.auto) return u.auto === 'explore' ? 'exploring' : 'automated';
if (u.path?.length) return 'moving';
if (u.stance === 'fortify') return 'fortified';
if (u.stance === 'sleep') return 'sleeping';
if (u.stance === 'alert') return 'alert';
// (A fighter on alert is on patrol: intercepting.)
if (u.stance === 'alert') return UNITS[u.type]?.class === 'fighter' ? 'patrol' : 'alert';
if (u.skip) return 'skipped';
return (u.moves ?? 0) > 0 ? 'idle' : 'done';
}
Expand Down
28 changes: 24 additions & 4 deletions src/games/blockilization/client/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 { isGreatPerson, isMilitary, UNITS } from '../rules/units';

Expand Down Expand Up @@ -101,8 +102,8 @@ export interface Store {
tap: { unit: number; tile: number } | null;
/** A city of yours taking aim (its ranged attack): the next click on the map is its target. */
cityTarget: number | null;
/** A unit of yours choosing where to go by air (a paratrooper's drop, an airlift): the next click on the map is where. */
unitAim: { unit: number; order: 'paradrop' | 'airlift' } | null;
/** A unit of yours choosing where to go by air (a paratrooper's drop, an airlift, an aircraft's rebase): the next click on the map is where. */
unitAim: { unit: number; order: 'paradrop' | 'airlift' | 'rebase' } | null;
/** A move just ordered by a click: the unit, where to, and once the server has taken it, when (this screen's time): its destination's marker pulses, then the next unit can be selected. */
ordered: { unit: number; to: number; at: number; taken: boolean; advanced?: boolean } | null;
/** A question waiting for a yes (the sandboxed frame has no `confirm()`). */
Expand Down Expand Up @@ -315,10 +316,27 @@ export function enemyAt(t: number): boolean {
return unitsAt(t).some((u) => war(u.owner)) || (!!city && war(city.owner));
}

/** How far a unit reaches with an attack: its range if it fires (the Range promotion one more), else next door. */
/** How far a unit reaches with an attack: its range if it fires or flies (more with promotions: Range, Air Range), else next door. */
export function reachOf(u: UnitWire): number {
const type = UNITS[u.type];
return type?.ranged ? (type.range ?? 1) + (u.promotions?.includes('range') ? 1 : 0) : 1;
if (!type || (!type.ranged && type.domain !== 'air')) return 1;
return (type.range ?? 1) + (u.promotions ?? []).reduce((s, id) => s + (PROMOTIONS[id]?.range ?? 0), 0);
}

/** Whether a unit is a nuclear weapon (it strikes a tile, not a unit: `nukeable`). */
export const isNuke = (u: UnitWire) => !!UNITS[u.type]?.abilities?.includes('nuke');

/**
* Whether a nuclear weapon could be aimed at a tile, as far as this screen knows: a known tile in its
* range with an enemy there, or in an enemy's land. (The rules refuse it if the blast would reach
* anyone you're not at war with: the strike's confirmation says so.)
*/
export function nukeable(u: UnitWire, t: number): boolean {
const v = store.view;
const g = store.grid;
if (!v || !g || t === u.tile || !store.known[t] || distance(g, u.tile, t) > reachOf(u)) return false;
const owner = store.known[t]!.owner;
return enemyAt(t) || (owner >= 0 && owner !== me() && !!v.players[owner]?.war);
}

/**
Expand All @@ -335,6 +353,8 @@ export function outOfReach(u: UnitWire, t: number): string {
export function attackable(u: UnitWire, t: number): boolean {
const type = UNITS[u.type];
const g = store.grid;
// (A nuclear weapon has no strength of its own: it strikes where it's aimed.)
if (type && isNuke(u)) return nukeable(u, t);
if (!type || !isMilitary(type) || type.abilities?.includes('no_attack') || !g) return false;
if (!store.known[t]?.visible || !enemyAt(t)) return false;
// A ranged shot from the ground (as the rules' `ranged`): not from the water, and only at an enemy
Expand Down
18 changes: 14 additions & 4 deletions src/games/blockilization/client/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1364,14 +1364,18 @@ function renderUnit(): El | null {
// (Why the advisor marks no work for it, when it marks none.)
const quiet = workQuiet();
if (u.pillage) act('Pillage', { t: 'pillage', unit: u.id }, 'Pillage what stands here: gold, and it heals; the land yields nothing until a worker repairs it', 'axe');
// By air: a paratrooper's drop, an airlift between airports; the next click on the map says where.
const aim = (order: 'paradrop' | 'airlift') => () => {
// By air: a paratrooper's drop, an airlift between airports, an aircraft's rebase; the next click on the map says where.
const aim = (order: 'paradrop' | 'airlift' | 'rebase') => () => {
store.unitAim = store.unitAim?.unit === u.id && store.unitAim.order === order ? null : { unit: u.id, order };
store.changed++;
};
const city = cityAt(u.tile);
if (type.abilities?.includes('paradrop')) act('Paradrop', aim('paradrop'), `Drop it on empty land you know, within ${PARADROP_RANGE} tiles (from your own land, before it moves)`, 'plane', store.unitAim?.unit === u.id ? '.gold' : '');
if (type.domain === 'land' && city?.owner === me() && city.buildings?.includes('airport')) act('Airlift', aim('airlift'), 'Fly it to another of your cities with an airport (before it moves)', 'jet', store.unitAim?.unit === u.id ? '.gold' : '');
if (type.domain === 'air') {
const why = (u.moves ?? 0) <= 0 ? 'It has flown this turn already' : !u.bases?.length ? `None of your cities or ships with room for it within ${reachOf(u) * 2} tiles` : undefined;
act('Rebase', aim('rebase'), why ?? `Fly it to one of your cities or ships with room for it, within ${reachOf(u) * 2} tiles (they're outlined in blue)`, 'plane', store.unitAim?.unit === u.id ? '.gold' : '', !!why);
}
// A soldier in (or next to) a friendly city-state's land can be given to it, for influence.
const cs = [u.tile, ...neighbours(store.grid!, u.tile)].map((t) => store.view!.players[store.known[t]?.owner ?? -1]).find((p) => p?.cityState && !p.war);
// (Not with another unit on its tile, as the rules' `giftUnit` has it.)
Expand Down Expand Up @@ -1412,6 +1416,8 @@ function renderUnit(): El | null {
? 'Shift-click more of your units to add them, or shift-drag a box round them (shift-click one in the group to take it out).'
: store.unitAim?.unit === u.id
? `${click(true)} where it should ${store.unitAim.order === 'paradrop' ? 'land' : 'fly to'}${touchScreen() ? '.' : ' (Esc to change your mind).'}`
: type.abilities?.includes('nuke')
? `${click(true)} an enemy, or their land, within ${reachOf(u)} tiles to strike there (you'll be asked first), or one of your cities${u.type === 'atomic_bomb' ? ' or Carriers' : ', Missile Cruisers or Nuclear Submarines'} outlined in blue to rebase there. Everyone in the blast must be someone you're at war with.${u.type === 'atomic_bomb' ? ' Fighters and anti-aircraft guns near the target may shoot it down.' : ''}`
: u.auto === 'work'
? 'Automated: it improves your land by itself each turn. Any order takes it back.'
: u.auto === 'explore'
Expand All @@ -1425,7 +1431,7 @@ function renderUnit(): El | null {
: type.abilities?.includes('combat_bonus_aura')
? `${ABILITY_TEXT.combat_bonus_aura} Keep it with your army (it can share a tile with a soldier), out of the enemy's reach.`
: type.domain === 'air'
? `${click(true)} an enemy within ${reachOf(u)} tiles to strike it (they're ringed in red), or one of your cities or carriers within ${reachOf(u) * 2} to rebase there. Fighters and anti-aircraft guns near a target may intercept.`
? `${click(true)} an enemy within ${reachOf(u)} tiles to strike it (they're ringed in red), or one of your cities or ships outlined in blue to rebase there. Fighters and anti-aircraft guns near a target may intercept.${type.class === 'missile' ? ' The missile is spent in its strike.' : ''}`
: type.ranged
? `Enemies in reach are ringed in red: ${touchScreen() ? 'tap one to see how a shot would go, tap it again to fire' : 'point at one to see how a shot would go, click to fire'}.`
: type.strength
Expand Down Expand Up @@ -2620,7 +2626,11 @@ function renderTip() {
if (store.unitAim) bits.push(h('div.ln.way', {}, icon('plane', 13), `${click(true)} to ${store.unitAim.order === 'paradrop' ? 'drop' : 'fly'} it here`));
else {
bits.push(...work);
if (sel && t !== sel.tile && !attackable(sel, t)) {
// (An aircraft rebases, rather than going by a way: where it could, or why not.)
if (sel && t !== sel.tile && !attackable(sel, t) && UNITS[sel.type]?.domain === 'air') {
const base = sel.bases?.includes(t);
bits.push(h(`div.ln.way${base ? '' : '.no'}`, {}, icon(base ? 'plane' : 'close', 13), base ? `${click(true)} to rebase it here` : `It rebases only to your cities and ships with room for it, within ${reachOf(sel) * 2} tiles`));
} else if (sel && t !== sel.tile && !attackable(sel, t)) {
const route = previewPath(sel, t);
bits.push(h(`div.ln.way${route ? '' : '.no'}`, {}, icon(route ? 'moves' : 'close', 13), route ? wayThere(route, sel) : whyNot(sel, t)));
const walls = route && wallsOnWay(route, sel);
Expand Down
2 changes: 2 additions & 0 deletions src/games/blockilization/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ export interface UnitWire {
destinations?: { city: number; name: string; gold: number; science: number; food: number; prod: number; home: boolean }[];
/** Own units: whether it can pillage where it stands now. */
pillage?: boolean;
/** Own aircraft: the tiles it could rebase to now (its cities and ships with room for it, in reach). */
bases?: number[];
/** Own military units: their strength defending where they stand, and what changes it. */
defence?: { strength: number; mods: Modifier[] };
/** Own units: what they can be upgraded to now, and for how much gold. */
Expand Down
Loading
Loading