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
5 changes: 3 additions & 2 deletions src/games/blockilization/client/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { marchingAt } from './models3d';
import { drawPins } from './pins';
import { drawPings } from './chat';
import { drawWorkTips } from './workadvice';
import { BUILD_GLYPH } from './ui';
import { previewPath, routeOf, type Route } from './path';
import { mapFont } from './pixel';
import { cityAt, enemyAt, me, myUnit, reachOf, attackable, store, TEAM_COLORS, tone } from './store';
Expand Down Expand Up @@ -706,8 +707,8 @@ export function drawOverlay(now: number) {
ctx.textBaseline = 'middle';
shadowText(label, tx + tw / 2, ty + th / 2 + 1, '#f2c14e');
}
// Fortified, asleep, working: a small sign at its corner.
const sign = u.stance === 'fortify' ? 'shield' : u.stance === 'sleep' ? 'turn' : u.work ? 'hammer' : '';
// Fortified, asleep, working (the sign of what it's building): a small sign at its corner.
const sign = u.stance === 'fortify' ? 'shield' : u.stance === 'sleep' ? 'turn' : u.work ? (BUILD_GLYPH[u.work.what] ?? 'hammer') : '';
if (sign && own) {
ctx.fillStyle = 'rgba(13,19,27,0.9)';
dotPath(x + fw / 2, y - fw / 2, size * 0.42);
Expand Down
6 changes: 4 additions & 2 deletions src/games/blockilization/client/overview.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { centre } from '../geometry';
import type { CityWire, DemographicId, UnitWire } from '../protocol';
import type { BuildWhat } from '../rules/game';
import { CIVS } from '../rules/civs';
import { distance } from '../rules/hex';
import { ERA_NAMES, ERAS } from '../rules/techs';
import { UNITS } from '../rules/units';
import { lookAt } from './camera';
import { emblemEl, GREAT_GLYPH, icon, svg, YIELD_COLOR } from './icons';
import { me, store } from './store';
import { askCity, cityBuild, h, itemArt, itemName, openCity, pic, portrait, tell, tile, togglePanel, type El } from './ui';
import { askCity, cityBuild, h, itemArt, itemName, openCity, pic, portrait, tell, tile, togglePanel, workWhen, workWords, type El } from './ui';
import { unitModel } from './models3d';
import { buildName } from './workadvice';
import { renderPolitics } from './politics';

/**
Expand Down Expand Up @@ -261,7 +263,7 @@ function renderUnits(): El {
return h(
`div.ov-unit${chosen ? '.chosen' : ''}`,
{ onclick: (e: MouseEvent) => select(u, e), title: 'Click to select it; shift-click to add it to the selected unit’s group' },
h('span.st', { style: `color:${STATUS[st][1]}` }, h('i.dot', { style: `background:${STATUS[st][1]}` }), st === 'working' && u.work ? `${STATUS[st][0]} (${u.work.turns}t)` : st === 'moving' ? `${STATUS[st][0]} (${u.path!.length} tile${u.path!.length === 1 ? '' : 's'})` : STATUS[st][0]),
h('span.st', { style: `color:${STATUS[st][1]}`, title: st === 'working' && u.work ? `${workWords(u.work.what, u.tile)}: ${workWhen(u.work.turns)}` : undefined }, h('i.dot', { style: `background:${STATUS[st][1]}` }), st === 'working' && u.work ? `${buildName(u.work.what as BuildWhat, u.tile)} (${u.work.turns}t)` : st === 'moving' ? `${STATUS[st][0]} (${u.path!.length} tile${u.path!.length === 1 ? '' : 's'})` : STATUS[st][0]),
u.embarked && h('span.cv-chip', {}, 'at sea'),
h('span.cv-grow.cv-muted', {}, whereIs(u)),
h('span.hp', {}, h(`div.cv-bar.${u.hp > 50 ? 'green' : u.hp > 25 ? 'gold' : 'red'}`, {}, h('i', { style: `width:${u.hp}%` }))),
Expand Down
7 changes: 7 additions & 0 deletions src/games/blockilization/client/pixel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const BTN_GREEN = button('#a6ec7c', '#2c6420');
const BTN_RED = button('#ee7c64', '#5e1c12');
const BTN_GOLD = button('#ffe48a', '#7a5a12');
const BTN_BLUE = button('#a8dcff', '#1e4a80');
const BTN_ORANGE = button('#ffcc88', '#7a3e10');
const CHIP = frame(1, ['O'], { O: '#07090c' }, 2, 0);
const CHIP_ON = frame(1, ['O'], { O: '#f2c14e' }, 2, 0);
const TIP = frame(2, ['O', 'bev'], { O: '#07090c', H: '#5d6884', L: '#0d1015' });
Expand All @@ -73,6 +74,7 @@ const FACE_GREEN = face('#6cc04c', '#5fb042');
const FACE_RED = face('#c04a34', '#b0412d');
const FACE_GOLD = face('#e8bc48', '#dcae3a');
const FACE_BLUE = face('#4a8ed8', '#3f80c8');
const FACE_ORANGE = face('#d97e30', '#c97028');
// Bars: blocks of 8 with a 2-pixel gap, each lit on top.
const blocks = (bc: string, bh: string, bl: string) => `background: repeating-linear-gradient(90deg, transparent 0 8px, #0f1218 8px 10px), linear-gradient(180deg, ${bh} 0 2px, ${bc} 2px calc(100% - 2px), ${bl} calc(100% - 2px)) !important;`;

Expand Down Expand Up @@ -210,6 +212,11 @@ export const PIXEL_CSS = `
.cv-unit .acts .cv-btn.small { padding: 2px 5px 3px !important; gap: 4px !important; font-size: 17px !important; }
.cv-unit .cv-ustats { font-size: 13px !important; }
.cv-unit .cv-ustats b { font-size: 22px !important; line-height: 1; ${SH} }
.cv-unit .cv-work { ${border(SLOT_SM)} background: #2c2519 !important; background-clip: padding-box !important; box-shadow: none !important; padding: 5px 8px 6px !important; }
.cv-unit .cv-work .going { font: 400 21px/1 ${D} !important; letter-spacing: .03em; ${SH} } .cv-unit .cv-work .left { color: #fff !important; }
.cv-unit .cv-work small { font-size: 13px !important; }
.cv-btn.on { ${border(BTN_ORANGE)} ${FACE_ORANGE} color: #fff !important; text-shadow: 2px 2px 0 #5a2a08 !important; cursor: default !important; }
.cv-btn.on:hover:not(.off), .cv-btn.on:active:not(.off) { ${FACE_ORANGE} transform: none !important; }
.cv-mod { font: 400 16px/1.1 ${D} !important; letter-spacing: .04em; padding: 1px 6px !important; }
.cv-mod.up { background: #24402a !important; box-shadow: inset 0 0 0 2px #3c6a3c; } .cv-mod.down { background: #4a2420 !important; box-shadow: inset 0 0 0 2px #7a3a30; }
.cv-defence { font-size: 14px !important; } .cv-defence b { font-size: 20px; ${SH} }
Expand Down
4 changes: 4 additions & 0 deletions src/games/blockilization/client/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ select.cv-input { width: auto; flex: 1; }
.cv-blocked > div { display: flex; align-items: center; gap: 6px; } .cv-blocked b { font-weight: 700; color: var(--text); }
.cv-unit .going { display: flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 700; color: #ffe3a1; }
.cv-unit .going.danger { color: #f0a090; }
.cv-unit .cv-work { display: flex; flex-direction: column; gap: 4px; padding: 7px 9px; border-radius: var(--r2); background: rgba(240,162,74,.1); box-shadow: inset 0 0 0 1px rgba(240,162,74,.3); }
.cv-unit .cv-work .going { color: #ffd9a8; } .cv-unit .cv-work .left { color: var(--text); white-space: nowrap; }
.cv-unit .cv-work small { font-size: 12px; } .cv-unit .cv-work .spent { color: #ffe3a1; }
.cv-btn.on { background: rgba(240,162,74,.18); box-shadow: inset 0 0 0 1px #f0a24a; color: #ffd9a8; cursor: default; }
.cv-unit .arrive { font-size: 12px; font-weight: 700; color: #cfe6ff; } .cv-unit .arrive:empty { display: none; } .cv-unit .arrive.no { color: #ff9a8e; }
.cv-promos { display: flex; flex-direction: column; gap: 5px; padding: 8px; border-radius: 10px; background: rgba(242,193,78,.1); border: 1px solid rgba(242,193,78,.3); }
.cv-promo { display: block; text-align: left; padding: 6px 9px; border-radius: 8px; background: rgba(242,193,78,.12); border: 1px solid rgba(242,193,78,.35) !important; transition: background .12s; }
Expand Down
70 changes: 60 additions & 10 deletions src/games/blockilization/client/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Client, ClientRooms } from '@platform/client';
import { centre } from '../geometry';
import type { CityInfo, CityWire, ClientToServer, LobbyAction, UnitWire, View } from '../protocol';
import { takenAlive } from '../rules/combat';
import { aName } from '../rules/core';
import { article, formationBonus, formationKind, unitName } from '../rules/armies';
import type { CombatPreview, PreviewSide } from '../rules/preview';
import { BUILDINGS, SPACESHIP_PARTS } from '../rules/buildings';
Expand Down Expand Up @@ -35,7 +36,7 @@ import { gameId, roomCode } from '../saves';
import { ADVICE_CSS, adviceTag, researchHint } from './advice';
import { POLITICS_CSS } from './politics';
import { heldTile, tileMenuFree } from './input';
import { workQuiet, workTip, workTipLines } from './workadvice';
import { workPlan, workQuiet, workTip, workTipLines } from './workadvice';
import { FAME_CSS, renderFame } from './fame';
import { defenceAdvice } from './defend';
import { closePinMenu, pinButtons, pinColor, pinMenuOpen, pinsButton, PINS_CSS, pinTip, renderPinList, renderPinMenu } from './pins';
Expand Down Expand Up @@ -1194,15 +1195,15 @@ function drawMinimap(cv: HTMLCanvasElement) {
// The selected unit
// -------------------------------------------------------------------------------------------------

/** Improvements a worker might build on its tile, and what each does there (the server checks it can). */
function buildOptions(t: number): { what: string; name: string; does: string }[] {
/** Improvements a worker might build on its tile, and what each does there (an improvement's yields and resource: `gives`). The server checks it can. */
function buildOptions(t: number): { what: string; name: string; does: string; gives?: string }[] {
const v = store.view!;
const techs = new Set(v.self.techs);
const s = store.terrain[t];
const k = store.known[t];
// (Nothing's built on a natural wonder, not even a road.)
if (!s || !k || k.wonder) return [];
const out: { what: string; name: string; does: string }[] = [];
const out: { what: string; name: string; does: string; gives?: string }[] = [];
// (No road on the water: an embarked worker builds none, as the rules' `canBuild` has it.)
if (!k.road && techs.has('the_wheel') && !TERRAINS[s.terrain].water) out.push({ what: 'road', name: 'Road', does: 'Faster moves, and a link from a city to your capital' });
if (k.road === 'road' && techs.has('railroad')) out.push({ what: 'rail', name: 'Railroad', does: 'Much faster moves' });
Expand All @@ -1217,7 +1218,8 @@ function buildOptions(t: number): { what: string; name: string; does: string }[]
if (imp.great || (imp.tech && !techs.has(imp.tech)) || k.improvement === id || cleared(id, t)) continue;
const res = k.resource && tapsWith({ resource: k.resource, terrain: s.terrain }) === id ? RESOURCES[k.resource] : undefined;
const gain = yieldWords(addYields(imp.yields, res?.improved ?? {}));
if (imp.on.includes('resource') ? res : fitsLand(id, t) || res) out.push({ what: id, name: imp.name, does: `${imp.name}: ${gain || 'no yields of its own'} here${res ? ` (with the ${res.name}${res.kind === 'bonus' ? '' : `: your empire gets the ${res.name}`})` : ''}` });
const gives = `${gain || 'no yields of its own'} here${res ? ` (with the ${res.name}${res.kind === 'bonus' ? '' : `: your empire gets the ${res.name}`})` : ''}`;
if (imp.on.includes('resource') ? res : fitsLand(id, t) || res) out.push({ what: id, name: imp.name, does: `${imp.name}: ${gives}`, gives });
}
if (s.feature && FEATURES[s.feature].removable && techs.has(FEATURES[s.feature].removable!.tech)) out.push({ what: 'remove_feature', name: `Clear ${FEATURES[s.feature].name}`, does: `Clear the ${FEATURES[s.feature].name}${FEATURES[s.feature].removable!.prod ? ': its wood is production for the nearest city' : ''}` });
return out;
Expand Down Expand Up @@ -1280,8 +1282,43 @@ function seaBlocked(t: number): { what: ImprovementId; name: string; why: string
export const BUILD_GLYPH: Record<string, string> = {
road: 'trade', rail: 'rail', repair: 'hammer', farm: 'wheat', mine: 'pick', pasture: 'cattle', camp: 'deer', plantation: 'leaf', quarry: 'stone',
trading_post: 'gold', lumber_mill: 'hammer', fishing_boats: 'fish', oil_well: 'oil', offshore_platform: 'oil', fort: 'shield', remove_feature: 'axe', scrub_fallout: 'drop',
landmark: 'pick', artifact: 'frame',
};

/** What a unit at work is doing on its tile, in words: 'Building a Farm', 'Clearing the Forest'. */
export function workWords(what: string, t: number): string {
const s = store.terrain[t];
const k = store.known[t];
if (what === 'road') return 'Building a Road';
if (what === 'rail') return 'Laying a Railroad';
if (what === 'repair') return `Repairing the pillaged ${k?.improvement ? (IMPROVEMENTS[k.improvement as ImprovementId]?.name ?? 'improvement') : 'Road'}`;
if (what === 'remove_feature') return `Clearing the ${s?.feature ? FEATURES[s.feature].name : 'land'}`;
if (what === 'scrub_fallout') return 'Scrubbing away the Fallout';
if (what === 'landmark') return 'Making the site a Landmark';
if (what === 'artifact') return 'Digging up an Artifact';
return `Building ${aName(IMPROVEMENTS[what as ImprovementId]?.name ?? what.replace('_', ' '))}`;
}

/** When a unit's work is done, in words: its turns left counted as `views.ts` counts them (this turn the first, if it has a move). */
export const workWhen = (turns: number) => (turns <= 1 ? 'done at the end of this turn' : `${turns} turns left`);

/**
* A unit at work's lines on its card: what it's doing, how far along, when it's done, and (an
* improvement) what that brings; or, with its moves spent, that the work only starts next turn.
*/
function workRow(u: UnitWire): El {
const w = u.work!;
const spent = (u.moves ?? 0) <= 0;
const gives = buildOptions(u.tile).find((b) => b.what === w.what)?.gives;
return h(
'div.cv-work',
{ title: `${Math.round(w.progress * 100)}% done` },
h('div.going', {}, icon(BUILD_GLYPH[w.what] ?? 'hammer', 14, '#f0a24a'), h('span.cv-grow', {}, workWords(w.what, u.tile)), h('span.left', {}, workWhen(w.turns))),
h('div.cv-bar.orange', {}, h('i', { style: `width:${Math.round(w.progress * 100)}%` })),
spent ? h('small.spent', {}, 'No moves left this turn: the work starts next turn') : gives ? h('small.cv-muted', {}, `When done: ${gives}`) : null,
);
}

/** The selected unit's card's line for where the mouse is (how many turns to get there), kept current every frame. */
let arrival: El | null = null;

Expand All @@ -1308,12 +1345,21 @@ function renderUnit(): El | null {
if (u.auto) act('Stop automating', { t: 'automate', unit: u.id, mode: null }, 'Take it back: it waits for your orders again', 'close', '.gold');
else if (type.abilities?.includes('build') || type.abilities?.includes('build_sea')) act('Automate', { t: 'automate', unit: u.id, mode: 'work' }, 'Let it improve your land by itself each turn (E): luxuries and resources first, then the tiles your cities work, then roads', 'gear');
else if (explores(u)) act('Explore', { t: 'automate', unit: u.id, mode: 'explore' }, `Let it explore by itself each turn, until there is nothing left to see${type.domain === 'sea' ? ' on the seas' : ''}; then it heads home (E)`, 'compass');
// (The advisor's recommended work here, if any, in gold: `workadvice.ts`.)
// (The advisor's recommended work here, if any, in gold; the work it's on its way to: `workadvice.ts`.)
const advised = workTip(u.tile);
const plan = workPlan(u.id);
const then = plan && workWords(plan.what, plan.tile);
// (A work boat builds fishing boats, or an offshore platform on Oil, on a sea resource in your waters, and is spent doing it.)
const sea = !type.abilities?.includes('build') && !!type.abilities?.includes('build_sea');
const builds = type.abilities?.includes('build') ? buildOptions(u.tile) : sea ? buildOptions(u.tile).filter((b) => SEA_WORK.includes(b.what)).map((b) => ({ ...b, does: `${b.does}. The ${type.name} is spent building it` })) : [];
for (const b of builds) act(b.name, { t: 'build', unit: u.id, what: b.what as never }, advised?.what === b.what ? `Advised: ${advised.why}. ${b.does}` : b.does, BUILD_GLYPH[b.what] ?? 'hammer', advised?.what === b.what || sea ? '.gold' : '');
// (At work: its work's button marked as under way, and a word on each other that switching loses what's done.)
const busy = u.work && { ...u.work, words: workWords(u.work.what, u.tile) };
for (const b of builds)
if (busy?.what === b.what) act(b.name, () => {}, `${busy.words}: ${workWhen(busy.turns)}`, BUILD_GLYPH[b.what] ?? 'hammer', '.on');
else {
const does = advised?.what === b.what ? `Advised: ${advised.why}. ${b.does}` : b.does;
act(b.name, { t: 'build', unit: u.id, what: b.what as never }, busy ? `${does}. Stops what it's doing now (${busy.words.toLowerCase()}) and starts this instead: the work done so far is lost` : does, BUILD_GLYPH[b.what] ?? 'hammer', advised?.what === b.what || sea ? '.gold' : '');
}
const blocked = type.abilities?.includes('build') ? buildBlocked(u.tile) : sea ? seaBlocked(u.tile) : [];
// (Why the advisor marks no work for it, when it marks none.)
const quiet = workQuiet();
Expand Down Expand Up @@ -1372,7 +1418,9 @@ function renderUnit(): El | null {
? 'Exploring by itself each turn. Any order takes it back.'
: type.abilities?.includes('found_city')
? 'Move to good land (rivers, coast, food), then found a city.'
: type.abilities?.includes('build')
: u.work
? 'Moving it or giving it other work stops this, and what it has done is lost.'
: type.abilities?.includes('build')
? 'Improve tiles your cities work: farms, mines, roads.'
: 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.`
Expand Down Expand Up @@ -1408,7 +1456,6 @@ function renderUnit(): El | null {
type.ranged ? h('span', { title: 'Ranged strength · range' }, icon('ranged', 14, '#6ab8ff'), h('b', {}, `${type.ranged}`), formed, ` · ${reachOf(u)}`) : null,
h('span', { title: 'Moves' }, icon('moves', 14, '#7bd16a'), h('b', {}, `${Math.round((u.moves ?? 0) * 10) / 10}/${u.maxMoves ?? type.moves}`)),
u.xp ? h('span', { title: 'Experience' }, icon('xp', 14, '#f2c14e'), h('b', {}, String(u.xp))) : null,
u.work ? h('span', {}, icon('hammer', 14), h('b', {}, `${u.work.turns} turn${u.work.turns === 1 ? '' : 's'}`)) : null,
),
),
),
Expand All @@ -1425,8 +1472,11 @@ function renderUnit(): El | null {
),
// (Standing where an enemy city's walls reach: they fire at it each turn, fortified or not.)
isMilitary(type) && type.domain !== 'air' && wallsOver(u.tile) && h('div.going.danger', {}, icon('war', 14, '#e0604a'), h('span', {}, `In range of ${wallsOver(u.tile)!.name}'s walls: they fire at it each turn`)),
// At work: what, how far along, when it's done.
u.work && workRow(u),
// Its orders: where it's going, and when it gets there; the units gathered with it.
u.path?.length ? h('div.going', {}, icon('moves', 14, '#ffd23a'), h('span', {}, `Moving to ${placeName(u.path[u.path.length - 1])} · ${arrives(routeOf(u))}${marching(u.id) ? ' (with its group, at the pace of the slowest)' : ''}`)) : null,
// (A worker sent to recommended work: what it builds when it gets there, `workadvice.ts`.)
u.path?.length ? h('div.going', {}, icon('moves', 14, '#ffd23a'), h('span', {}, `Moving to ${placeName(u.path[u.path.length - 1])} · ${arrives(routeOf(u))}${marching(u.id) ? ' (with its group, at the pace of the slowest)' : ''}${then ? `, then ${then.charAt(0).toLowerCase()}${then.slice(1)}` : ''}`)) : null,
// (One of a march that's ahead waits a turn for the slowest, with no way of its own to show.)
!u.path?.length &&
marchOf(u.id) &&
Expand Down
4 changes: 4 additions & 0 deletions src/games/blockilization/client/workadvice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ let asked = '';
/** Workers on their way to recommended work: where, what to build there, where they set out from and when. */
const plans = new Map<number, { tile: number; what: BuildWhat; from: number; turn: number }>();

/** Where a worker of yours is on its way to recommended work, and what it builds there. */
export const workPlan = (unit: number) => plans.get(unit);

/** A new game: the last one's plans (and advice) go (its unit ids mean other units now). */
export function resetWorkPlans() {
plans.clear();
Expand Down Expand Up @@ -71,6 +74,7 @@ export function buildName(what: BuildWhat, t: number): string {
if (what === 'rail') return 'Railroad';
if (what === 'repair') return 'Repair';
if (what === 'scrub_fallout') return 'Scrub Fallout';
if (what === 'artifact') return 'Artifact';
if (what === 'remove_feature') {
const f = store.terrain[t]?.feature;
return f ? `Clear the ${FEATURES[f].name}` : 'Clear the land';
Expand Down
3 changes: 2 additions & 1 deletion src/games/blockilization/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ export interface UnitWire {
maxMoves?: number;
stance?: string;
path?: number[];
work?: { what: string; turns: number };
/** Own units at work: what (an improvement's id, or `road`, `repair`, `remove_feature`...), the turns it has left (this one counted if it has a move to spend), how far along it is (0 to 1). */
work?: { what: string; turns: number; progress: number };
xp?: number;
attacked?: boolean;
setUp?: boolean;
Expand Down
Loading
Loading