diff --git a/assets/images/civilization/civilization-terrain.png b/assets/images/civilization/civilization-terrain.png index 0a21416..6ab4ed8 100644 Binary files a/assets/images/civilization/civilization-terrain.png and b/assets/images/civilization/civilization-terrain.png differ diff --git a/assets/images/civilization/civilization-terrain.psd b/assets/images/civilization/civilization-terrain.psd index 5fc2840..f3e26a1 100644 Binary files a/assets/images/civilization/civilization-terrain.psd and b/assets/images/civilization/civilization-terrain.psd differ diff --git a/assets/images/civilization/civilization-units.png b/assets/images/civilization/civilization-units.png new file mode 100644 index 0000000..efcab07 Binary files /dev/null and b/assets/images/civilization/civilization-units.png differ diff --git a/assets/images/civilization/civilization-units.psd b/assets/images/civilization/civilization-units.psd new file mode 100644 index 0000000..e553481 Binary files /dev/null and b/assets/images/civilization/civilization-units.psd differ diff --git a/data/civilization-artwork.json b/data/civilization-artwork.json index 679a3b7..a73940e 100644 --- a/data/civilization-artwork.json +++ b/data/civilization-artwork.json @@ -11,7 +11,7 @@ "terrainSheet": { "key": "civilization-terrain", "path": "assets/images/civilization/civilization-terrain.png", "frameWidth": 128, "frameHeight": 96 }, "resourceSheet": { "key": "civilization-resources", "path": null, "frameWidth": 64, "frameHeight": 64 }, "improvementSheet": { "key": "civilization-improvements", "path": null, "frameWidth": 64, "frameHeight": 64 }, - "unitSheet": { "key": "civilization-units", "path": null, "frameWidth": 64, "frameHeight": 64 }, + "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": null, "frameWidth": 128, "frameHeight": 96 } diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 93e14d6..1bc2a1c 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -8,6 +8,7 @@ import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; +import { TextInput } from '../../ui/TextInput.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { compileRules, turnToYear, formatYear } from './CivilizationRules.js'; import * as Logic from './CivilizationLogic.js'; @@ -32,6 +33,7 @@ export default class CivilizationGame extends Phaser.Scene { this.phase = 'setup'; this.modalOpen = false; this.busy = false; + this.endTurnFlashTween = null; } create() { @@ -382,7 +384,7 @@ export default class CivilizationGame extends Phaser.Scene { }); this.input.on('wheel', (pointer, objs, dx, dy) => { if (this.modalOpen || this.phase !== 'playing') return; - this.view.zoomBy(dy > 0 ? -1 : 1); + this.view.zoomBy(dy > 0 ? -1 : 1, pointer.x, pointer.y); }); } @@ -425,7 +427,7 @@ export default class CivilizationGame extends Phaser.Scene { moveTo(unit, c, r) { if (this.state.current !== this.state.humanIndex) return; if (Logic.cheb(unit.x, unit.y, c, r) === 1) { - this.tryStep(unit, c - unit.x, r - unit.y); + this.tryStep(unit, c - unit.x, r - unit.y, true); return; } const path = Logic.findPath(this.rules, this.state, unit, c, r); @@ -508,6 +510,28 @@ export default class CivilizationGame extends Phaser.Scene { if (unit) this.view.centerOnIfOffscreen?.(unit.x, unit.y); this.view.refresh(); this.refreshUnitPanel(); + // Nothing left to move this turn — nudge the player toward End Turn. + const noneLeft = !unit && this.phase === 'playing' && !this.busy + && this.state?.current === this.state?.humanIndex; + this.setEndTurnFlash(noneLeft); + } + + setEndTurnFlash(on) { + if (on === !!this.endTurnFlashTween) return; + if (!on) { + this.endTurnFlashTween?.stop(); + this.endTurnFlashTween = null; + this.endTurnBtn?.setAlpha(1); + return; + } + this.endTurnFlashTween = this.tweens.add({ + targets: this.endTurnBtn, + alpha: 0.55, + duration: 550, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut', + }); } selectNextUnit() { @@ -521,49 +545,68 @@ export default class CivilizationGame extends Phaser.Scene { this.view.centerOn(next.x, next.y); } - tryStep(unit, dx, dy) { + tryStep(unit, dx, dy, animate = false) { if (this.state.current !== this.state.humanIndex) return; + const from = [unit.x, unit.y]; const out = unit.carriedBy ? Logic.disembark(this.rules, this.state, unit, dx, dy) : Logic.tryMove(this.rules, this.state, unit, dx, dy); if (out.result === 'blocked' && out.needsWar !== undefined) { this.confirmWar(out.needsWar, () => { Logic.declareWar(this.rules, this.state, this.state.humanIndex, out.needsWar); - this.tryStep(unit, dx, dy); + this.tryStep(unit, dx, dy, animate); }); return; } if (out.result === 'invalid') return; if (out.hut) this.toastHut(out.hut); - this.afterAction(); - if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit(); - if (!this.state.units.includes(unit)) this.selectNextUnit(); + const finish = () => { + this.afterAction(); + if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit(); + if (!this.state.units.includes(unit)) this.selectNextUnit(); + }; + const moved = unit.x !== from[0] || unit.y !== from[1]; + if (!animate || !moved) { finish(); return; } + this.animateMoveThen([from, [unit.x, unit.y]], finish); } walkPath(unit, path) { - // Step along the found path until movement runs out or something happens. + // Resolve the whole turn's worth of movement instantly (combat, huts, + // etc. all need the real logic), then replay the tiles actually crossed + // as a smooth glide so the player sees continuous motion, not a snap. + const visited = [[unit.x, unit.y]]; let i = 0; - const step = () => { - if (i >= path.length || unit.mp <= 0 || !this.state.units.includes(unit) || this.state.over) { - this.view.showPath(null); - this.afterAction(); - if (!this.state.units.includes(unit) || unit.mp <= 0) this.selectNextUnit(); - return; - } + let hutEvent = null; + while (i < path.length && unit.mp > 0 && this.state.units.includes(unit) && !this.state.over) { const [nx, ny] = path[i]; i += 1; const out = Logic.tryMove(this.rules, this.state, unit, Math.sign(nx - unit.x), Math.sign(ny - unit.y)); - if (out.result === 'invalid' || out.result === 'blocked') { - this.view.showPath(null); - this.afterAction(); - return; - } - if (out.hut) this.toastHut(out.hut); - this.view.refresh(); - this.time.delayedCall(60, step); + if (out.result === 'invalid' || out.result === 'blocked') break; + if (out.hut) hutEvent = out.hut; + visited.push([unit.x, unit.y]); + } + if (hutEvent) this.toastHut(hutEvent); + const finish = () => { + this.view.showPath(null); + this.afterAction(); + if (!this.state.units.includes(unit) || unit.mp <= 0) this.selectNextUnit(); }; - step(); + this.animateMoveThen(visited, finish); + } + + // Glides the selected unit's marker smoothly across `tiles` + // ([[c,r], ...], already-resolved positions) over 1s, pauses 250ms at the + // destination, then invokes `finish`. Falls straight through to `finish` + // when there's nothing to animate (unit didn't actually move). + animateMoveThen(tiles, finish) { + if (tiles.length < 2) { finish(); return; } + this.view.refresh(); + this.busy = true; + this.view.animateUnitAlong(tiles, 1000, 250, () => { + this.busy = false; + finish(); + }); } tryFound(unit) { @@ -573,13 +616,55 @@ export default class CivilizationGame extends Phaser.Scene { this.toast('Cannot found a city here'); return; } - const city = Logic.foundCity(this.rules, this.state, unit); - if (city) { - this.toast(`${city.name} founded!`); - this.view.repaintTileAndNeighbors(city.x, city.y); - this.afterAction(); - this.selectNextUnit(); - } + if (this.modalOpen) return; + const defaultName = Logic.peekCityName(this.rules, this.state, unit.civ); + this.promptCityName(defaultName, (name) => { + const city = Logic.foundCity(this.rules, this.state, unit, name); + if (city) { + this.toast(`${city.name} founded!`); + this.view.repaintTileAndNeighbors(city.x, city.y); + this.afterAction(); + this.selectNextUnit(); + } + }); + } + + promptCityName(defaultName, onConfirm) { + this.modalOpen = true; + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + 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, cy, 560, 240, COLORS.panel).setStrokeStyle(2, COLORS.accent); + const title = this.add.text(cx, cy - 84, 'Found City', { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.accentHex, + }).setOrigin(0.5); + const label = this.add.text(cx, cy - 40, 'Name this city:', { + fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, + }).setOrigin(0.5); + root.add([dim, panel, title, label]); + + const input = new TextInput(this, cx, cy + 4, { + width: 380, height: 48, value: defaultName, maxLength: 24, autocomplete: 'off', + }); + input.focus(); + input.el.select(); + + const close = () => { input.destroy(); root.destroy(true); this.modalOpen = false; }; + const confirm = () => { + const name = input.value.trim() || defaultName; + close(); + onConfirm(name); + }; + input.on('keydown', (e) => { + if (e.key === 'Enter') confirm(); + else if (e.key === 'Escape') close(); + }); + + const found = new Button(this, cx - 110, cy + 68, 'FOUND', confirm, { width: 180, height: 52 }); + const cancel = new Button(this, cx + 110, cy + 68, 'CANCEL', close, + { width: 180, height: 52, variant: 'ghost' }); + root.add([found, cancel]); } tryWork(unit, impId) { @@ -645,6 +730,7 @@ export default class CivilizationGame extends Phaser.Scene { } runToHumanTurn() { + this.setEndTurnFlash(false); this.busy = true; this.endTurnBtn?.setAlpha(0.4); this.refreshUnitPanel(); diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index b6593f6..09e1650 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -378,16 +378,28 @@ export function cityYields(rules, state, city) { // --------------------------------------------------------------------------- // Cities -export function nextCityName(rules, state, civ) { +// Read-only preview of the name a new city would get, without consuming it +// from the pool — lets UI show a default before the player confirms. +export function peekCityName(rules, state, civ) { const c = state.civs[civ]; if (civCities(state, civ).length === 0) return `${c.name} City`; const pool = rules.cityNames; const idx = c.nameOrder[c.nameCursor % pool.length]; const round = Math.floor(c.nameCursor / pool.length); - c.nameCursor += state.civs.length; return round > 0 ? `${pool[idx]} ${'I'.repeat(round + 1)}` : pool[idx]; } +function advanceCityNameCursor(state, civ) { + if (civCities(state, civ).length === 0) return; // first city never draws from the pool + state.civs[civ].nameCursor += state.civs.length; +} + +export function nextCityName(rules, state, civ) { + const name = peekCityName(rules, state, civ); + advanceCityNameCursor(state, civ); + return name; +} + export function canFoundCity(rules, state, x, y) { const terr = terrainAt(rules, state.world, x, y); if (terr.water || terr.id === 'glacier') return false; @@ -397,15 +409,17 @@ export function canFoundCity(rules, state, x, y) { return true; } -export function foundCity(rules, state, unit) { +export function foundCity(rules, state, unit, name) { if (!canFoundCity(rules, state, unit.x, unit.y)) return null; const civ = state.civs[unit.civ]; + const cityName = name && name.trim() ? name.trim() : peekCityName(rules, state, unit.civ); + advanceCityNameCursor(state, unit.civ); const city = { id: state.nextCityId, civ: unit.civ, x: unit.x, y: unit.y, - name: nextCityName(rules, state, unit.civ), + name: cityName, size: 1, foodBox: 0, shieldBox: 0, diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index aef8d83..3ad71f2 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -20,8 +20,9 @@ import { export const TILE_W = 128; export const TILE_H = 64; export const FRAME_H = 96; // sprite frames carry 32px of headroom above the diamond +export const UNIT_FRAME_H = 96; // unit frames get the same 32px headroom as terrain -const ZOOMS = [0.5, 0.75, 1.0]; +const ZOOMS = [0.5, 0.75, 1.0, 1.5, 2.0]; export class CivilizationMapView { constructor(scene, rules, state, callbacks = {}) { @@ -140,12 +141,25 @@ export class CivilizationMapView { return best; } - setZoom(idx) { + setZoom(idx, mouseX, mouseY) { + const oldScale = this.root.scaleX; this.zoomIdx = Phaser.Math.Clamp(idx, 0, ZOOMS.length - 1); this.root.setScale(ZOOMS[this.zoomIdx]); + const newScale = this.root.scaleX; + + // Zoom toward mouse position if provided. + if (mouseX != null && mouseY != null) { + // Convert screen mouse position to world space using old scale. + const worldX = (mouseX - this.root.x) / oldScale; + const worldY = (mouseY - this.root.y) / oldScale; + // Adjust root position so that world point stays under the mouse. + this.root.x += worldX * (oldScale - newScale); + this.root.y += worldY * (oldScale - newScale); + } + this.clampPan(); } - zoomBy(delta) { this.setZoom(this.zoomIdx + delta); } + zoomBy(delta, mouseX, mouseY) { this.setZoom(this.zoomIdx + delta, mouseX, mouseY); } panBy(dx, dy) { this.root.x += dx; @@ -517,7 +531,10 @@ export class CivilizationMapView { const label = scene.add.text(0, TILE_H + 10, `${city.size} ${city.name}`, { fontFamily: '"Julius Sans One"', fontSize: '15px', color: civ.color, }).setOrigin(0.5); - banner.width = label.width + 16; + // Direct `.width =` leaves the cached display origin stale (it's only + // recomputed by setSize/setOrigin), so the box renders left-anchored + // instead of staying centered under the label — use setSize instead. + banner.setSize(label.width + 16, 22); container.add([banner, label]); container.setDepth(y + 1); banner.setInteractive({ useHandCursor: true }); @@ -537,9 +554,13 @@ export class CivilizationMapView { const y = this.isoY(c, r) + TILE_H / 2; const container = scene.add.container(x, y); - if (scene.textures.exists('civilization-units')) { + // Sprite mode: the 64x96 unit frame carries the same 32px headroom as + // terrain, so its bottom 64px (the diamond zone) sits over the tile and + // the top 32px rises into the tile behind it, like a tree or peak does. + const spriteMode = scene.textures.exists('civilization-units'); + if (spriteMode) { const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1); - const img = scene.add.image(0, -6, 'civilization-units', def.frame); + const img = scene.add.image(0, TILE_H / 2 - UNIT_FRAME_H / 2, 'civilization-units', def.frame); container.add([ring, img]); } else { const g = scene.add.graphics(); @@ -555,12 +576,15 @@ export class CivilizationMapView { }).setOrigin(0.5); container.add(label); } + // Badges ride at shoulder height: low against the flat procedural + // roundel, higher up against the taller sprite frame. + const badgeY = spriteMode ? -28 : -14; if (unit.vet) { - container.add(scene.add.circle(12, -14, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6)); + container.add(scene.add.circle(12, badgeY, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6)); } if (stackCount > 1) { - const badge = scene.add.circle(-16, -14, 8, 0x000000, 0.8); - const num = scene.add.text(-16, -14, `${stackCount}`, { + const badge = scene.add.circle(-16, badgeY, 8, 0x000000, 0.8); + const num = scene.add.text(-16, badgeY, `${stackCount}`, { fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff', }).setOrigin(0.5); container.add([badge, num]); @@ -571,6 +595,7 @@ export class CivilizationMapView { } drawSelection() { + this.selectionRing = null; if (!this.selectedUnitId) return; const unit = this.state.units.find((u) => u.id === this.selectedUnitId); if (!unit) { this.selectedUnitId = null; return; } @@ -579,11 +604,51 @@ export class CivilizationMapView { const ring = this.scene.add.circle(x, y - 2, 24).setStrokeStyle(3, 0xffffff, 1); ring.setDepth(y + 3); this.dynamic.add(ring); + this.selectionRing = ring; this.scene.tweens.add({ targets: ring, alpha: 0.25, duration: 420, yoyo: true, repeat: -1, }); } + // Glides the currently-selected unit's marker (and its selection ring) + // through `tiles` ([[c,r], ...]) — state is already at the final tile by + // the time this runs, so we snap the marker back to the first point and + // tween forward, splitting `duration` evenly across the hops. Pauses + // `pauseMs` on arrival before calling `onComplete`. + animateUnitAlong(tiles, duration, pauseMs, onComplete) { + const container = this.selectedContainer; + const points = tiles.map(([c, r]) => ({ + x: this.isoX(c, r), y: this.isoY(c, r) + TILE_H / 2, + })); + if (!container || points.length < 2) { onComplete?.(); return; } + const ring = this.selectionRing; + const place = (p) => { + container.setPosition(p.x, p.y); + container.setDepth(p.y + 2); + if (ring) { ring.setPosition(p.x, p.y - 2); ring.setDepth(p.y + 3); } + }; + place(points[0]); + const segDuration = duration / (points.length - 1); + let idx = 0; + const nextSeg = () => { + idx += 1; + const target = points[idx]; + this.scene.tweens.add({ + targets: container, + x: target.x, + y: target.y, + duration: segDuration, + ease: 'Sine.easeInOut', + onUpdate: () => place({ x: container.x, y: container.y }), + onComplete: () => { + if (idx < points.length - 1) nextSeg(); + else this.scene.time.delayedCall(pauseMs, () => onComplete?.()); + }, + }); + }; + nextSeg(); + } + showPath(path) { this.pathGfx.clear(); if (!path || !path.length) return; diff --git a/src/games/civilization/sprites.md b/src/games/civilization/sprites.md index 2e1bc48..e5768d4 100644 --- a/src/games/civilization/sprites.md +++ b/src/games/civilization/sprites.md @@ -136,14 +136,24 @@ vector-drawn (they connect dynamically between tiles), so they are NOT here. ## 4. Units sheet — `civilization-units.png` Unit art is **neutral** (no civ color baked in): the game draws a player-color -ring/roundel underneath your frame. Center the unit in the frame with its feet -around y = 52 so it sits on the tile. +ring/roundel underneath your frame. Frames use the classic pronounced look — +taller than the tile, so the figure stands up off the diamond instead of +being squashed flat into it. + +The frame carries the same **32px headroom** convention as the terrain sheet: +the bottom 64px is the diamond zone (matches the 128×64 tile footprint, +just narrower), the top 32px is headroom the figure's head/torso rises into, +overlapping the tile behind it exactly the way a tree or mountain peak does +on the terrain sheet. Plant the feet around **y = 84** (within the diamond +zone, forward of center toward the tile's front tip) and let the body extend +upward from there — most of a standing unit's height should land in the +headroom band (y = 0–32), not the diamond zone. | | | |---|---| | **Path** | `public/assets/images/civilization/civilization-units.png` | -| **Sheet size** | **512 × 448 px** | -| **Frame size** | **64 × 64 px** | +| **Sheet size** | **512 × 672 px** | +| **Frame size** | **64 × 96 px** | | **Layout** | 8 columns × 7 rows = 56 frames (51 used, 52–55 reserved) | | **Status** | ❌ not created — roundel + 2-letter label renders meanwhile | | **JSON** | `unitSheet` | @@ -269,7 +279,7 @@ city. The menu shows a generic fallback until painted. |---|---|---|---| | 1 | `civilization-terrain.png` | 768×192 | **High** — biggest visual upgrade | | 2 | `civilization-cities-classic.png` | 512×192 | **High** | -| 3 | `civilization-units.png` | 512×448 | **High** (51 frames — biggest lift) | +| 3 | `civilization-units.png` | 512×672 | **High** (51 frames — biggest lift) | | 4 | `civilization-resources.png` | 320×256 | Medium | | 5 | `civilization-improvements.png` | 384×64 | Medium | | 6 | `civilization-icons.png` | 480×96 | Low |