From 4659a990f78a2c9709d0911afbf27d58bbde048a Mon Sep 17 00:00:00 2001 From: YfengJ <166808804+YfengJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:27:59 +0800 Subject: [PATCH 1/2] feat: add Civ Lite production and victory loops --- demo/civ-lite/game.js | 242 ++++++++++++++++-------- demo/civ-lite/index.html | 10 + demo/civ-lite/production-model.js | 106 +++++++++++ demo/civ-lite/production-model.test.mjs | 94 +++++++++ demo/civ-lite/styles.css | 58 ++++++ 5 files changed, 426 insertions(+), 84 deletions(-) create mode 100644 demo/civ-lite/production-model.js create mode 100644 demo/civ-lite/production-model.test.mjs diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 2428a1f..5c480a5 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -5,6 +5,13 @@ and Unciv tile groups. ───────────────────────────────────────────────────── */ import { buildSpriteAtlas } from './sprites.js'; +import { + createProductionQueue, + enqueueProduction, + evaluateVictory, + movementPlanForPath, + progressProductionQueue, +} from './production-model.js'; // ─── Constants ────────────────────────────────────── const MAP_W = 24, MAP_H = 16, TILE = 48; @@ -21,6 +28,14 @@ const TERRAIN_YIELD = { desert: { food: 0, prod: 1, sci: 1 }, }; const TERRAIN_DEFENSE = { plains: 0, forest: 0.25, hill: 0.35, water: 0, desert: 0 }; +const PRODUCTION_OPTIONS = [ + { id: 'warrior', label: 'Warrior', cost: 12, unitType: 'warrior' }, + { id: 'scout', label: 'Scout', cost: 8, unitType: 'scout' }, +]; +const UNIT_STATS = { + warrior: { hp: 100, atk: 28, def: 15, mov: 2 }, + scout: { hp: 80, atk: 18, def: 10, mov: 3 }, +}; // ─── DOM refs ─────────────────────────────────────── const canvas = document.getElementById('gameCanvas'); @@ -36,6 +51,7 @@ const dom = { prod: document.getElementById('prod'), science: document.getElementById('science'), unitDet: document.getElementById('unitDetails'), + cityDet: document.getElementById('cityDetails'), logBox: document.getElementById('logContent'), }; @@ -95,8 +111,8 @@ const S = { { id: 2, owner: 'bot', x: MAP_W - 3, y: MAP_H - 3, hp: 100, atk: 28, def: 18, mov: 2, movLeft: 2, type: 'warrior' }, ], cities: [ - { owner: 'player', x: 2, y: 2, name: 'Athens', food: 0, prod: 0, pop: 1 }, - { owner: 'bot', x: MAP_W - 3, y: MAP_H - 3, name: 'Babylon', food: 0, prod: 0, pop: 1 }, + { owner: 'player', x: 2, y: 2, name: 'Athens', food: 0, prod: 0, pop: 1, productionQueue: createProductionQueue(PRODUCTION_OPTIONS) }, + { owner: 'bot', x: MAP_W - 3, y: MAP_H - 3, name: 'Babylon', food: 0, prod: 0, pop: 1, productionQueue: createProductionQueue(PRODUCTION_OPTIONS) }, ], turn: 1, phase: 'player', // player | bot | animating | gameover @@ -116,6 +132,8 @@ const S = { tileVariants: [], }; +for (const city of S.cities) enqueueProduction(city.productionQueue, 'warrior'); + // Init fog for (let y = 0; y < MAP_H; y++) { S.fog[y] = []; @@ -240,16 +258,13 @@ function combat(attacker, defender) { } function checkWin() { - const playerUnits = S.units.filter(u => u.owner === 'player'); - const botUnits = S.units.filter(u => u.owner === 'bot'); - const playerCities = S.cities.filter(c => c.owner === 'player'); - const botCities = S.cities.filter(c => c.owner === 'bot'); - if (botUnits.length === 0 && botCities.length === 0) { + const result = evaluateVictory(S); + if (result.winner === 'player') { S.phase = 'gameover'; dom.status.textContent = '🎉 Victory!'; dom.status.className = 'status-win'; log('*** VICTORY! ***', 'build'); - } else if (playerUnits.length === 0 && playerCities.length === 0) { + } else if (result.winner === 'bot') { S.phase = 'gameover'; dom.status.textContent = '💀 Defeat'; dom.status.className = 'status-lose'; @@ -284,23 +299,55 @@ function updateParticles() { } // ─── City production ──────────────────────────────── +function spawnUnitFromProduction(owner, city, item) { + const stats = UNIT_STATS[item.unitType] || UNIT_STATS.warrior; + const id = S.nextId++; + let spawnX = city.x, spawnY = city.y; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1]]) { + const nx = city.x + dx, ny = city.y + dy; + if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H && + S.map[ny][nx] !== 'water' && + !S.units.find(u => u.x === nx && u.y === ny)) { + spawnX = nx; spawnY = ny; break; + } + } + S.units.push({ + id, + owner, + x: spawnX, + y: spawnY, + hp: stats.hp, + atk: stats.atk, + def: stats.def, + mov: stats.mov, + movLeft: stats.mov, + type: item.unitType, + }); + log(`${city.name} completed ${item.label}!`, 'build'); + if (owner === 'player') revealAround(spawnX, spawnY); +} + function gatherResources(owner) { const cities = S.cities.filter(c => c.owner === owner); let totalFood = 0, totalProd = 0, totalSci = 0; for (const city of cities) { + let cityFood = 0, cityProd = 0, citySci = 0; // Gather yields from surrounding tiles for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { const nx = city.x + dx, ny = city.y + dy; if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H) { const y = TERRAIN_YIELD[S.map[ny][nx]]; - totalFood += y.food; - totalProd += y.prod; - totalSci += y.sci; + cityFood += y.food; + cityProd += y.prod; + citySci += y.sci; } } - city.food += totalFood; - city.prod += totalProd; + totalFood += cityFood; + totalProd += cityProd; + totalSci += citySci; + + city.food += cityFood; // City growth if (city.food >= 8 * city.pop) { @@ -308,24 +355,15 @@ function gatherResources(owner) { city.food = 0; log(`${city.name} grows to pop ${city.pop}!`, 'build'); } - // Auto-recruit - if (city.prod >= 12) { - city.prod -= 12; - const id = S.nextId++; - let spawnX = city.x, spawnY = city.y; - // Find empty adjacent tile - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1]]) { - const nx = city.x + dx, ny = city.y + dy; - if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H && - S.map[ny][nx] !== 'water' && - !S.units.find(u => u.x === nx && u.y === ny)) { - spawnX = nx; spawnY = ny; break; - } - } - S.units.push({ id, owner, x: spawnX, y: spawnY, hp: 100, atk: 28, def: 15, mov: 2, movLeft: 2, type: 'warrior' }); - log(`${city.name} trained a warrior!`, 'build'); - if (owner === 'player') revealAround(spawnX, spawnY); + + if (owner === 'bot' && city.productionQueue.items.length === 0) { + enqueueProduction(city.productionQueue, 'warrior'); } + + const result = progressProductionQueue(city.productionQueue, cityProd); + city.productionQueue = result.queue; + city.prod = city.productionQueue.progress; + result.completed.forEach(item => spawnUnitFromProduction(owner, city, item)); } return { food: totalFood, prod: totalProd, sci: totalSci }; } @@ -345,6 +383,7 @@ function endPlayerTurn() { dom.food.textContent = res.food; dom.prod.textContent = res.prod; dom.science.textContent = res.sci; + updateCityPanel(); setTimeout(botTurn, 400); } @@ -357,6 +396,7 @@ function startPlayerTurn() { dom.status.className = ''; S.units.filter(u => u.owner === 'player').forEach(u => { u.movLeft = u.mov; }); refreshVision(); + updateCityPanel(); log(`─── Turn ${S.turn} ───`); } @@ -387,34 +427,33 @@ function botTurn() { bot.movLeft = 0; // Check city capture const cap = S.cities.find(c => c.owner === 'player' && c.x === bot.x && c.y === bot.y); - if (cap) { cap.owner = 'bot'; log(`Bot captured ${cap.name}!`, 'combat'); } + if (cap) { cap.owner = 'bot'; log(`Bot captured ${cap.name}!`, 'combat'); checkWin(); updateCityPanel(); } continue; } // Move toward target const path = findPath(bot.x, bot.y, bestTarget.x, bestTarget.y); if (path.length > 1) { - let steps = 0, movLeft = bot.movLeft; - for (let i = 1; i < path.length && movLeft > 0; i++) { - const cost = moveCost(path[i].x, path[i].y); - if (cost > movLeft) break; - // Check blocking - if (S.units.find(u => u.x === path[i].x && u.y === path[i].y && u !== bot)) { - if (S.units.find(u => u.x === path[i].x && u.y === path[i].y && u.owner === 'player')) { - combat(bot, S.units.find(u => u.x === path[i].x && u.y === path[i].y && u.owner === 'player')); - movLeft = 0; - } - break; - } - bot.x = path[i].x; - bot.y = path[i].y; - movLeft -= cost; - steps++; + const plan = movementPlanForPath(path, { + movementLeft: bot.movLeft, + costForTile: tile => moveCost(tile.x, tile.y), + isBlocked: tile => Boolean(S.units.find(u => u.x === tile.x && u.y === tile.y && u !== bot)), + }); + if (plan.steps.length > 0) { + const last = plan.steps.at(-1); + bot.x = last.x; + bot.y = last.y; + } + bot.movLeft = plan.movementLeft; + const blockedTile = path[plan.steps.length + 1]; + const blocker = blockedTile && S.units.find(u => u.x === blockedTile.x && u.y === blockedTile.y && u.owner === 'player'); + if (blocker && Math.abs(blocker.x - bot.x) + Math.abs(blocker.y - bot.y) === 1) { + combat(bot, blocker); + bot.movLeft = 0; } - bot.movLeft = movLeft; // Capture city const cap = S.cities.find(c => c.owner === 'player' && c.x === bot.x && c.y === bot.y); - if (cap) { cap.owner = 'bot'; log(`Bot captured ${cap.name}!`, 'combat'); } + if (cap) { cap.owner = 'bot'; log(`Bot captured ${cap.name}!`, 'combat'); checkWin(); updateCityPanel(); } } } @@ -790,6 +829,44 @@ function updateUnitPanel() { } } +function updateCityPanel() { + const city = S.cities.find(c => c.owner === 'player'); + if (!city) { + dom.cityDet.innerHTML = '

No city remains

'; + return; + } + + const current = city.productionQueue.items[0]; + const pct = current ? Math.min(100, Math.round((city.productionQueue.progress / current.cost) * 100)) : 0; + const queueText = city.productionQueue.items.length > 1 + ? city.productionQueue.items.slice(1).map(item => item.label).join(' → ') + : 'No queued follow-up'; + + dom.cityDet.innerHTML = ` +
+ ${city.name} + Pop ${city.pop} +
+
+ ${current ? current.label : 'Idle'} + ${current ? `${city.productionQueue.progress}/${current.cost}` : '0/0'} +
+
+
+
+
Next: ${queueText}
+
Win: capture Babylon or remove all bot pieces.
`; +} + +function queueCityProduction(itemId) { + if (S.phase === 'gameover') return; + const city = S.cities.find(c => c.owner === 'player'); + if (!city) return; + const item = enqueueProduction(city.productionQueue, itemId).items.at(-1); + log(`${city.name} queued ${item.label}`, 'build'); + updateCityPanel(); +} + // ─── Tooltip ──────────────────────────────────────── function showTooltip(x, y, tileX, tileY) { if (tileX < 0 || tileX >= MAP_W || tileY < 0 || tileY >= MAP_H) { hideTooltip(); return; } @@ -924,44 +1001,38 @@ function handleClick(tx, ty) { const path = findPath(S.selected.x, S.selected.y, tx, ty); if (path.length > 1) { - // Calculate how far we can go - let stepsCanTake = 0, movLeft = S.selected.movLeft; - for (let i = 1; i < path.length; i++) { - const cost = moveCost(path[i].x, path[i].y); - if (cost > movLeft) break; - // Block on own units - if (S.units.find(u => u.x === path[i].x && u.y === path[i].y && u !== S.selected)) { - // If enemy, attack - const blocker = S.units.find(u => u.x === path[i].x && u.y === path[i].y); - if (blocker && blocker.owner === 'bot') { - // Move to previous tile then attack - const movePath = path.slice(0, i); - if (movePath.length > 1) { - const sel = S.selected; - animateMove(sel, movePath, () => { - sel.movLeft = movLeft; - combat(sel, blocker); - sel.movLeft = 0; - S.reachable.clear(); - updateUnitPanel(); - refreshVision(); - }); - return; - } else { - combat(S.selected, blocker); - S.selected.movLeft = 0; - } - } - break; + const sel = S.selected; + const plan = movementPlanForPath(path, { + movementLeft: sel.movLeft, + costForTile: tile => moveCost(tile.x, tile.y), + isBlocked: tile => Boolean(S.units.find(u => u.x === tile.x && u.y === tile.y && u !== sel)), + }); + const blockedTile = path[plan.steps.length + 1]; + const blocker = blockedTile && S.units.find(u => u.x === blockedTile.x && u.y === blockedTile.y && u !== sel); + + if (blocker && blocker.owner === 'bot') { + const movePath = [path[0], ...plan.steps]; + if (movePath.length > 1) { + animateMove(sel, movePath, () => { + combat(sel, blocker); + sel.movLeft = 0; + S.reachable.clear(); + updateUnitPanel(); + refreshVision(); + }); + return; } - movLeft -= cost; - stepsCanTake = i; + combat(sel, blocker); + sel.movLeft = 0; + S.reachable.clear(); + updateUnitPanel(); + refreshVision(); + return; } - if (stepsCanTake > 0) { - const walkPath = path.slice(0, stepsCanTake + 1); - const finalMovLeft = movLeft; - const sel = S.selected; + if (plan.steps.length > 0) { + const walkPath = [path[0], ...plan.steps]; + const finalMovLeft = plan.movementLeft; animateMove(sel, walkPath, () => { sel.movLeft = finalMovLeft; S.reachable = calcReachable(sel); @@ -1017,6 +1088,8 @@ document.getElementById('btnCenter').addEventListener('click', () => { const u = S.selected || S.units.find(u => u.owner === 'player'); if (u) centerOn(u.x, u.y); }); +document.getElementById('btnQueueWarrior').addEventListener('click', () => queueCityProduction('warrior')); +document.getElementById('btnQueueScout').addEventListener('click', () => queueCityProduction('scout')); // ─── Main loop ────────────────────────────────────── let lastTime = 0; @@ -1090,5 +1163,6 @@ function gameLoop(now) { log('Loading sprites…'); ATLAS = await buildSpriteAtlas(); log('Sprites loaded — game starting!', 'good'); + updateCityPanel(); requestAnimationFrame(gameLoop); })(); diff --git a/demo/civ-lite/index.html b/demo/civ-lite/index.html index 163f984..e10aeb3 100644 --- a/demo/civ-lite/index.html +++ b/demo/civ-lite/index.html @@ -4,6 +4,7 @@ CIV Lite – Human vs Bots + @@ -31,6 +32,15 @@

Selected Unit

+
+

City Production

+
+
+ + +
+
+
diff --git a/demo/civ-lite/production-model.js b/demo/civ-lite/production-model.js new file mode 100644 index 0000000..7a24449 --- /dev/null +++ b/demo/civ-lite/production-model.js @@ -0,0 +1,106 @@ +export const DEFAULT_PRODUCTION_OPTIONS = [ + { id: 'warrior', label: 'Warrior', cost: 12, unitType: 'warrior' }, + { id: 'scout', label: 'Scout', cost: 8, unitType: 'scout' }, +]; + +export function createProductionQueue(options = DEFAULT_PRODUCTION_OPTIONS) { + return { + options: options.map(option => ({ ...option })), + items: [], + progress: 0, + }; +} + +export function enqueueProduction(queue, itemId) { + const option = queue.options.find(candidate => candidate.id === itemId); + if (!option) throw new Error(`Unknown production item: ${itemId}`); + queue.items.push({ ...option }); + return queue; +} + +export function progressProductionQueue(queue, production) { + let available = Math.max(0, production); + const completed = []; + let nextQueue = { + options: queue.options.map(option => ({ ...option })), + items: queue.items.map(item => ({ ...item })), + progress: queue.progress, + }; + + while (available > 0 && nextQueue.items.length > 0) { + const current = nextQueue.items[0]; + const remaining = current.cost - nextQueue.progress; + if (available < remaining) { + nextQueue.progress += available; + available = 0; + } else { + available -= remaining; + completed.push(current); + nextQueue.items.shift(); + nextQueue.progress = 0; + } + } + + return { queue: nextQueue, completed }; +} + +export function movementPlanForPath(path, options) { + const steps = []; + let movementLeft = Math.max(0, options.movementLeft ?? 0); + let stopReason = null; + + for (let i = 1; i < path.length; i++) { + const tile = path[i]; + if (options.isBlocked?.(tile)) { + stopReason = 'blocked'; + break; + } + + const cost = options.costForTile(tile); + if (!Number.isFinite(cost) || cost <= 0) { + stopReason = 'impassable'; + break; + } + + if (cost > movementLeft) { + stopReason = 'movement'; + break; + } + + steps.push({ ...tile }); + movementLeft -= cost; + } + + return { steps, movementLeft, stopReason }; +} + +export function evaluateVictory(state) { + const playerPieces = countOwnedPieces(state, 'player'); + const botPieces = countOwnedPieces(state, 'bot'); + const playerCities = state.cities.filter(city => city.owner === 'player').length; + const botCities = state.cities.filter(city => city.owner === 'bot').length; + + if (botPieces === 0 && playerPieces > 0) { + return { phase: 'gameover', winner: 'player', reason: 'conquest' }; + } + + if (playerPieces === 0 && botPieces > 0) { + return { phase: 'gameover', winner: 'bot', reason: 'conquest' }; + } + + if (botCities === 0 && playerCities > 0) { + return { phase: 'gameover', winner: 'player', reason: 'domination' }; + } + + if (playerCities === 0 && botCities > 0) { + return { phase: 'gameover', winner: 'bot', reason: 'domination' }; + } + + return { phase: 'active', winner: null, reason: null }; +} + +function countOwnedPieces(state, owner) { + const units = state.units.filter(unit => unit.owner === owner).length; + const cities = state.cities.filter(city => city.owner === owner).length; + return units + cities; +} diff --git a/demo/civ-lite/production-model.test.mjs b/demo/civ-lite/production-model.test.mjs new file mode 100644 index 0000000..6c4a264 --- /dev/null +++ b/demo/civ-lite/production-model.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createProductionQueue, + enqueueProduction, + progressProductionQueue, + movementPlanForPath, + evaluateVictory, +} from './production-model.js'; + +test('production queue completes the front item and carries overflow into the next item', () => { + const queue = createProductionQueue([ + { id: 'warrior', label: 'Warrior', cost: 12, unitType: 'warrior' }, + { id: 'scout', label: 'Scout', cost: 8, unitType: 'scout' }, + ]); + + const updatedQueue = enqueueProduction(queue, 'warrior'); + enqueueProduction(updatedQueue, 'scout'); + + const result = progressProductionQueue(updatedQueue, 15); + + assert.equal(result.completed.length, 1); + assert.equal(result.completed[0].id, 'warrior'); + assert.equal(result.queue.items[0].id, 'scout'); + assert.equal(result.queue.progress, 3); +}); + +test('movement plan spends terrain costs without entering blocked or unaffordable tiles', () => { + const path = [ + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 3, y: 1 }, + { x: 4, y: 1 }, + ]; + const costs = new Map([ + ['2,1', 1], + ['3,1', 2], + ['4,1', 1], + ]); + const occupied = new Set(['4,1']); + + const plan = movementPlanForPath(path, { + movementLeft: 3, + costForTile: tile => costs.get(`${tile.x},${tile.y}`), + isBlocked: tile => occupied.has(`${tile.x},${tile.y}`), + }); + + assert.deepEqual(plan.steps, [{ x: 2, y: 1 }, { x: 3, y: 1 }]); + assert.equal(plan.movementLeft, 0); + assert.equal(plan.stopReason, 'blocked'); +}); + +test('victory evaluation reports conquest and domination states', () => { + assert.deepEqual( + evaluateVictory({ + units: [{ owner: 'player' }], + cities: [{ owner: 'player' }], + }), + { phase: 'gameover', winner: 'player', reason: 'conquest' }, + ); + + assert.deepEqual( + evaluateVictory({ + units: [{ owner: 'bot' }], + cities: [{ owner: 'bot' }], + }), + { phase: 'gameover', winner: 'bot', reason: 'conquest' }, + ); + + assert.deepEqual( + evaluateVictory({ + units: [{ owner: 'player' }, { owner: 'bot' }], + cities: [{ owner: 'player' }], + }), + { phase: 'gameover', winner: 'player', reason: 'domination' }, + ); + + assert.deepEqual( + evaluateVictory({ + units: [{ owner: 'player' }, { owner: 'bot' }], + cities: [{ owner: 'bot' }], + }), + { phase: 'gameover', winner: 'bot', reason: 'domination' }, + ); + + assert.deepEqual( + evaluateVictory({ + units: [{ owner: 'player' }, { owner: 'bot' }], + cities: [{ owner: 'player' }, { owner: 'bot' }], + }), + { phase: 'active', winner: null, reason: null }, + ); +}); diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css index 249777a..9920142 100644 --- a/demo/civ-lite/styles.css +++ b/demo/civ-lite/styles.css @@ -187,6 +187,64 @@ main { .stat-val.hp-mid { color: var(--gold); } .stat-val.hp-low { color: var(--danger); } +/* ───── City Production ─────────────────────────── */ +#cityDetails { + display: grid; + gap: 6px; + font-size: 0.76rem; +} + +.city-row, +.queue-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.city-name, +.queue-name { + font-weight: 700; + color: var(--text); +} + +.queue-meta { + color: var(--dim); + white-space: nowrap; +} + +.queue-track { + width: 100%; + height: 6px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 999px; + background: #0b111b; +} + +.queue-fill { + height: 100%; + width: var(--pct, 0%); + background: linear-gradient(90deg, var(--accent), var(--accent2)); +} + +.queue-list { + color: var(--dim); + line-height: 1.35; +} + +.production-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin-top: 8px; +} + +.production-actions button { + padding: 6px 8px; + font-size: 0.76rem; +} + /* ───── Actions ──────────────────────────────────── */ #actions { display: flex; From 3caed488797e3ea6ae3a59eb42c7719f951432d5 Mon Sep 17 00:00:00 2001 From: YfengJ <166808804+YfengJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:30:51 +0800 Subject: [PATCH 2/2] chore: clean up movement blockers --- demo/civ-lite/game.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 5c480a5..25f22f4 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -437,7 +437,7 @@ function botTurn() { const plan = movementPlanForPath(path, { movementLeft: bot.movLeft, costForTile: tile => moveCost(tile.x, tile.y), - isBlocked: tile => Boolean(S.units.find(u => u.x === tile.x && u.y === tile.y && u !== bot)), + isBlocked: tile => S.units.some(u => u.x === tile.x && u.y === tile.y && u !== bot), }); if (plan.steps.length > 0) { const last = plan.steps.at(-1); @@ -1005,7 +1005,7 @@ function handleClick(tx, ty) { const plan = movementPlanForPath(path, { movementLeft: sel.movLeft, costForTile: tile => moveCost(tile.x, tile.y), - isBlocked: tile => Boolean(S.units.find(u => u.x === tile.x && u.y === tile.y && u !== sel)), + isBlocked: tile => S.units.some(u => u.x === tile.x && u.y === tile.y && u !== sel), }); const blockedTile = path[plan.steps.length + 1]; const blocker = blockedTile && S.units.find(u => u.x === blockedTile.x && u.y === blockedTile.y && u !== sel);