From 04155c234bada036c23df9cb9dcabc79f4acf63b Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:49:36 +0100 Subject: [PATCH] Refine UnitLayer mover rendering pipeline Detailed changelog: - keep persistent dynamic mover canvas rendering with overlap-safe conflict redraw groups backed by a spatial hash index - maintain budgeted bucket passes while fixing round-robin starvation by advancing cursors with actual scanned items - keep mover sampling + draw separation via cached samples and per-unit render rect tracking for precise clear/update paths - render moving trade/transport ships as explicit 5x5 mask glyphs matching sprite geometry/color semantics (territory/border) - optimize mask cross rendering by drawing the center cross with 2 rectangles and ring cells via mask iteration - retain configurable dynamic mover supersampling and composition smoothing experiment hooks - expose mover subpixel snap control tied to dynamic canvas scale for seam mitigation experiments - preserve trail update integration and mover-state bookkeeping with the revised dynamic draw pipeline --- src/client/graphics/layers/UnitLayer.ts | 697 ++++++++++++++++++++++-- 1 file changed, 648 insertions(+), 49 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 0dfd3b191..d74820cc1 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -31,12 +31,30 @@ enum Relationship { Enemy, } -const UNIT_DRAW_BUDGET_MS = 1; +const UNIT_DRAW_BUDGET_MS = 2; const UNIT_DRAW_SOFT_OVERRUN_MS = 1; -const OFFSCREEN_REFRESH_EVERY_N_FRAMES = 6; +const OFFSCREEN_REFRESH_EVERY_N_FRAMES = 60; const ONSCREEN_HYSTERESIS_FRAMES = 2; const OFFSCREEN_VERIFY_MAX_PER_FRAME = 12; const VIEW_PADDING_PX = 12; +const MOVER_SPATIAL_HASH_CELL_PX = 24; +const DYNAMIC_MOVER_CANVAS_SCALE = 5; +const DYNAMIC_MOVER_SUBPIXEL_SNAP = false; +const SMALL_SHIP_MASK_SIZE = 5; +const TRANSPORT_SHIP_MASK = [ + "..B..", + ".BTB.", + "BTTTB", + ".BTB.", + "..B..", +] as const; +const TRADE_SHIP_MASK = [ + "..T..", + ".TBT.", + "TBBBT", + ".TBT.", + "..T..", +] as const; type TransportTrailState = { xy: number[]; @@ -53,6 +71,22 @@ type MoverSpriteRect = { h: number; }; +type MoverRenderSample = { + unitId: number; + unit: UnitView; + planId: number; + x: number; + y: number; + renderX: number; + renderY: number; + rect: MoverSpriteRect; +}; + +type MoverSpatialIndex = { + cells: Map>; + unitToCells: Map; +}; + type MoverRenderState = { planId: number; lastSpriteRect: MoverSpriteRect | null; @@ -369,6 +403,8 @@ export class UnitLayer implements Layer { this.game.width(), this.game.height(), ); + context.save(); + context.imageSmoothingEnabled = true; context.drawImage( this.dynamicMoverCanvas, -this.game.width() / 2, @@ -376,6 +412,7 @@ export class UnitLayer implements Layer { this.game.width(), this.game.height(), ); + context.restore(); let totalOnScreenDebt = 0; let onScreenDebtCount = 0; @@ -417,6 +454,8 @@ export class UnitLayer implements Layer { } { const frameStartMs = performance.now(); const drawnIds = new Set(); + const sampledCache = new Map(); + const spatial = this.buildMoverSpatialHash(); let sampled = 0; let drawn = 0; @@ -430,6 +469,8 @@ export class UnitLayer implements Layer { frameStartMs, viewBounds, Number.MAX_SAFE_INTEGER, + sampledCache, + spatial, ); sampled += onScreenPass.sampled; drawn += onScreenPass.drawn; @@ -450,6 +491,8 @@ export class UnitLayer implements Layer { frameStartMs, viewBounds, OFFSCREEN_VERIFY_MAX_PER_FRAME, + sampledCache, + spatial, ); sampled += offscreenPass.sampled; drawn += offscreenPass.drawn; @@ -482,6 +525,8 @@ export class UnitLayer implements Layer { frameStartMs: number, viewBounds: { left: number; top: number; right: number; bottom: number }, maxItems: number, + sampledCache: Map, + spatial: MoverSpatialIndex, ): { sampled: number; drawn: number; @@ -502,13 +547,19 @@ export class UnitLayer implements Layer { let drawn = 0; let skipped = 0; let budgetRemaining = true; + const processed = new Set(); + let scanned = 0; for (let offset = 0; offset < cap; offset++) { if (bucketIds.length === 0) { break; } + scanned++; const idx = (startCursor + offset) % bucketIds.length; const unitId = bucketIds[idx]; + if (processed.has(unitId)) { + continue; + } const elapsedMs = performance.now() - frameStartMs; const canDrawWithinTarget = elapsedMs < UNIT_DRAW_BUDGET_MS; @@ -534,22 +585,29 @@ export class UnitLayer implements Layer { continue; } + const sampledCurrent = this.getMoverSample( + unitId, + unit, + plan.planId, + tickFloat, + sampledCache, + ); sampled++; - const sampledPos = sampleGridSegmentPlan(this.game, plan, tickFloat); - if (!sampledPos) { + if (!sampledCurrent) { skipped++; continue; } const onScreen = this.pointInView( - sampledPos.x, - sampledPos.y, + sampledCurrent.x, + sampledCurrent.y, viewBounds, VIEW_PADDING_PX, ); if (!onScreen) { if (state.lastOnScreen && state.lastSpriteRect) { + this.spatialRemove(spatial, unitId, state.lastSpriteRect); this.clearMoverRect(state.lastSpriteRect); state.lastSpriteRect = null; state.lastOnScreen = false; @@ -559,44 +617,73 @@ export class UnitLayer implements Layer { this.updateTransportShipTrail( unitId, plan.planId, - sampledPos.x, - sampledPos.y, + sampledCurrent.x, + sampledCurrent.y, false, ); } skipped++; + processed.add(unitId); continue; } this.moveMoverToBucket(unitId, state, "on"); - if (state.lastSpriteRect) { - this.clearMoverRect(state.lastSpriteRect); - } - const rect = this.drawSpriteAt( - unit, - sampledPos.x, - sampledPos.y, - this.dynamicMoverContext, - false, + let trailHandledInGroup = false; + const conflictIds = this.detectMoverConflicts( + unitId, + state.lastSpriteRect, + sampledCurrent.rect, + spatial, ); - if (!rect) { - skipped++; - continue; + if (conflictIds.size > 1) { + const groupResult = this.redrawConflictGroup( + conflictIds, + tickFloat, + viewBounds, + sampledCache, + spatial, + drawnIds, + processed, + ); + sampled += Math.max(0, groupResult.sampled - 1); + drawn += groupResult.drawn; + skipped += groupResult.skipped; + trailHandledInGroup = true; + } else { + if (state.lastSpriteRect) { + this.spatialRemove(spatial, unitId, state.lastSpriteRect); + this.clearMoverRect(state.lastSpriteRect); + } + + const rect = this.drawSpriteAt( + unit, + sampledCurrent.renderX, + sampledCurrent.renderY, + this.dynamicMoverContext, + false, + ); + if (!rect) { + skipped++; + processed.add(unitId); + continue; + } + + state.lastSpriteRect = rect; + state.lastOnScreen = true; + state.lastSeenFrame = this.renderFrame; + state.skipDebt = 0; + drawnIds.add(unitId); + drawn++; + processed.add(unitId); + this.spatialAdd(spatial, unitId, rect); } - state.lastSpriteRect = rect; - state.lastOnScreen = true; - state.lastSeenFrame = this.renderFrame; - state.skipDebt = 0; - drawnIds.add(unitId); - drawn++; - - if (unit.type() === UnitType.TransportShip) { + if (!trailHandledInGroup && unit.type() === UnitType.TransportShip) { this.updateTransportShipTrail( unitId, plan.planId, - sampledPos.x, - sampledPos.y, + sampledCurrent.x, + sampledCurrent.y, true, ); } @@ -605,18 +692,366 @@ export class UnitLayer implements Layer { if (bucket === "on") { this.onScreenCursor = bucketIds.length > 0 - ? (startCursor + Math.max(1, cap)) % bucketIds.length + ? (startCursor + Math.max(1, scanned)) % bucketIds.length : 0; } else { this.offScreenCursor = bucketIds.length > 0 - ? (startCursor + Math.max(1, cap)) % bucketIds.length + ? (startCursor + Math.max(1, scanned)) % bucketIds.length : 0; } return { sampled, drawn, skipped, budgetRemaining }; } + private buildMoverSpatialHash(): MoverSpatialIndex { + const spatial: MoverSpatialIndex = { + cells: new Map>(), + unitToCells: new Map(), + }; + + for (const [unitId, state] of this.moverState) { + if (!state.lastSpriteRect) { + continue; + } + this.spatialAdd(spatial, unitId, state.lastSpriteRect); + } + + return spatial; + } + + private getMoverSample( + unitId: number, + unit: UnitView, + planId: number, + tickFloat: number, + sampledCache: Map, + ): MoverRenderSample | null { + if (sampledCache.has(unitId)) { + return sampledCache.get(unitId) ?? null; + } + + const plan = this.game.motionPlans().get(unitId); + if (!plan || plan.planId !== planId) { + sampledCache.set(unitId, null); + return null; + } + + const sampled = sampleGridSegmentPlan(this.game, plan, tickFloat); + if (!sampled) { + sampledCache.set(unitId, null); + return null; + } + + const renderX = this.snapDynamicMoverCoord(sampled.x); + const renderY = this.snapDynamicMoverCoord(sampled.y); + const rect = this.computeSpriteRect(unit, renderX, renderY, false); + const result: MoverRenderSample = { + unitId, + unit, + planId, + x: sampled.x, + y: sampled.y, + renderX, + renderY, + rect, + }; + sampledCache.set(unitId, result); + return result; + } + + private detectMoverConflicts( + unitId: number, + oldRect: MoverSpriteRect | null, + newRect: MoverSpriteRect, + spatial: MoverSpatialIndex, + ): Set { + const conflictIds = new Set(); + conflictIds.add(unitId); + + const candidateIds = new Set(); + this.collectSpatialCandidates(candidateIds, spatial, newRect); + if (oldRect) { + this.collectSpatialCandidates(candidateIds, spatial, oldRect); + } + + for (const candidateId of candidateIds) { + if (candidateId === unitId) { + continue; + } + const candidateState = this.moverState.get(candidateId); + const candidateRect = candidateState?.lastSpriteRect; + if (!candidateRect) { + continue; + } + if ( + this.rectsOverlap(candidateRect, newRect) || + (oldRect !== null && this.rectsOverlap(candidateRect, oldRect)) + ) { + conflictIds.add(candidateId); + } + } + + return conflictIds; + } + + private redrawConflictGroup( + conflictIds: Set, + tickFloat: number, + viewBounds: { left: number; top: number; right: number; bottom: number }, + sampledCache: Map, + spatial: MoverSpatialIndex, + drawnIds: Set, + processed: Set, + ): { sampled: number; drawn: number; skipped: number } { + const sampledGroup: MoverRenderSample[] = []; + let sampled = 0; + let skipped = 0; + + for (const id of conflictIds) { + const unit = this.game.unit(id); + const plan = this.game.motionPlans().get(id); + const state = this.moverState.get(id); + if (!unit || !unit.isActive() || !plan || !state) { + this.clearMoverState(id); + processed.add(id); + skipped++; + continue; + } + + const current = this.getMoverSample( + id, + unit, + plan.planId, + tickFloat, + sampledCache, + ); + sampled++; + if (!current) { + processed.add(id); + skipped++; + continue; + } + + const onScreen = this.pointInView( + current.x, + current.y, + viewBounds, + VIEW_PADDING_PX, + ); + if (!onScreen) { + if (state.lastOnScreen && state.lastSpriteRect) { + this.spatialRemove(spatial, id, state.lastSpriteRect); + this.clearMoverRect(state.lastSpriteRect); + state.lastSpriteRect = null; + state.lastOnScreen = false; + } + this.moveMoverToBucket(id, state, "off"); + if (unit.type() === UnitType.TransportShip) { + this.updateTransportShipTrail( + id, + plan.planId, + current.x, + current.y, + false, + ); + } + processed.add(id); + skipped++; + continue; + } + + this.moveMoverToBucket(id, state, "on"); + sampledGroup.push(current); + } + + if (sampledGroup.length === 0) { + return { sampled, drawn: 0, skipped }; + } + + sampledGroup.sort((a, b) => a.unitId - b.unitId); + + let clearUnion: MoverSpriteRect | null = null; + for (const sampledCurrent of sampledGroup) { + const state = this.moverState.get(sampledCurrent.unitId); + if (!state) { + continue; + } + const oldRect = state.lastSpriteRect; + if (oldRect) { + this.spatialRemove(spatial, sampledCurrent.unitId, oldRect); + clearUnion = this.unionRects(clearUnion, oldRect); + } + clearUnion = this.unionRects(clearUnion, sampledCurrent.rect); + } + + if (clearUnion) { + this.clearMoverRect(clearUnion); + } + + let drawn = 0; + for (const sampledCurrent of sampledGroup) { + const state = this.moverState.get(sampledCurrent.unitId); + const plan = this.game.motionPlans().get(sampledCurrent.unitId); + if (!state || !plan) { + skipped++; + continue; + } + + const rect = this.drawSpriteAt( + sampledCurrent.unit, + sampledCurrent.renderX, + sampledCurrent.renderY, + this.dynamicMoverContext, + false, + ); + if (!rect) { + skipped++; + processed.add(sampledCurrent.unitId); + continue; + } + + state.lastSpriteRect = rect; + state.lastOnScreen = true; + state.lastSeenFrame = this.renderFrame; + state.skipDebt = 0; + this.spatialAdd(spatial, sampledCurrent.unitId, rect); + + if (sampledCurrent.unit.type() === UnitType.TransportShip) { + this.updateTransportShipTrail( + sampledCurrent.unitId, + plan.planId, + sampledCurrent.x, + sampledCurrent.y, + true, + ); + } + + drawnIds.add(sampledCurrent.unitId); + processed.add(sampledCurrent.unitId); + drawn++; + } + + return { sampled, drawn, skipped }; + } + + private snapDynamicMoverCoord(value: number): number { + if (!DYNAMIC_MOVER_SUBPIXEL_SNAP || DYNAMIC_MOVER_CANVAS_SCALE <= 0) { + return value; + } + return ( + Math.round(value * DYNAMIC_MOVER_CANVAS_SCALE) / + DYNAMIC_MOVER_CANVAS_SCALE + ); + } + + private spatialAdd( + spatial: MoverSpatialIndex, + unitId: number, + rect: MoverSpriteRect, + ): void { + const keys = this.rectSpatialKeys(rect); + if (keys.length === 0) { + spatial.unitToCells.delete(unitId); + return; + } + + spatial.unitToCells.set(unitId, keys); + for (const key of keys) { + let cell = spatial.cells.get(key); + if (!cell) { + cell = new Set(); + spatial.cells.set(key, cell); + } + cell.add(unitId); + } + } + + private spatialRemove( + spatial: MoverSpatialIndex, + unitId: number, + rect?: MoverSpriteRect | null, + ): void { + let keys = spatial.unitToCells.get(unitId); + if (!keys && rect) { + keys = this.rectSpatialKeys(rect); + } + if (!keys) { + return; + } + + for (const key of keys) { + const cell = spatial.cells.get(key); + if (!cell) { + continue; + } + cell.delete(unitId); + if (cell.size === 0) { + spatial.cells.delete(key); + } + } + spatial.unitToCells.delete(unitId); + } + + private collectSpatialCandidates( + candidateIds: Set, + spatial: MoverSpatialIndex, + rect: MoverSpriteRect, + ): void { + const keys = this.rectSpatialKeys(rect); + for (const key of keys) { + const cell = spatial.cells.get(key); + if (!cell) { + continue; + } + for (const id of cell) { + candidateIds.add(id); + } + } + } + + private rectSpatialKeys(rect: MoverSpriteRect): string[] { + const minCellX = Math.floor(rect.x / MOVER_SPATIAL_HASH_CELL_PX); + const maxCellX = Math.floor( + (rect.x + Math.max(1, rect.w) - 1) / MOVER_SPATIAL_HASH_CELL_PX, + ); + const minCellY = Math.floor(rect.y / MOVER_SPATIAL_HASH_CELL_PX); + const maxCellY = Math.floor( + (rect.y + Math.max(1, rect.h) - 1) / MOVER_SPATIAL_HASH_CELL_PX, + ); + + const keys: string[] = []; + for (let cx = minCellX; cx <= maxCellX; cx++) { + for (let cy = minCellY; cy <= maxCellY; cy++) { + keys.push(`${cx},${cy}`); + } + } + return keys; + } + + private rectsOverlap(a: MoverSpriteRect, b: MoverSpriteRect): boolean { + return ( + a.x < b.x + b.w && + a.x + a.w > b.x && + a.y < b.y + b.h && + a.y + a.h > b.y + ); + } + + private unionRects( + a: MoverSpriteRect | null, + b: MoverSpriteRect, + ): MoverSpriteRect { + if (a === null) { + return { ...b }; + } + const x1 = Math.min(a.x, b.x); + const y1 = Math.min(a.y, b.y); + const x2 = Math.max(a.x + a.w, b.x + b.w); + const y2 = Math.max(a.y + a.h, b.y + b.h); + return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; + } + onAlternativeViewEvent(event: AlternateViewEvent) { this.alternateView = event.alternateView; this.redraw(); @@ -633,6 +1068,7 @@ export class UnitLayer implements Layer { if (dynamicMoverContext === null) throw new Error("2d context not supported"); this.dynamicMoverContext = dynamicMoverContext; + this.dynamicMoverContext.imageSmoothingEnabled = false; this.trailCanvas = document.createElement("canvas"); const trailContext = this.trailCanvas.getContext("2d"); @@ -641,8 +1077,17 @@ export class UnitLayer implements Layer { this.canvas.width = this.game.width(); this.canvas.height = this.game.height(); - this.dynamicMoverCanvas.width = this.game.width(); - this.dynamicMoverCanvas.height = this.game.height(); + this.dynamicMoverCanvas.width = this.game.width() * DYNAMIC_MOVER_CANVAS_SCALE; + this.dynamicMoverCanvas.height = + this.game.height() * DYNAMIC_MOVER_CANVAS_SCALE; + this.dynamicMoverContext.setTransform( + DYNAMIC_MOVER_CANVAS_SCALE, + 0, + 0, + DYNAMIC_MOVER_CANVAS_SCALE, + 0, + 0, + ); this.trailCanvas.width = this.game.width(); this.trailCanvas.height = this.game.height(); @@ -1308,14 +1753,10 @@ export class UnitLayer implements Layer { context.clearRect(x, y, 1, 1); } - private drawSpriteAt( + private resolveSprite( unit: UnitView, - x: number, - y: number, - ctx: CanvasRenderingContext2D = this.context, - roundCoords: boolean = true, customTerritoryColor?: Colord, - ): MoverSpriteRect | null { + ): CanvasImageSource { let alternateViewColor: Colord | null = null; if (this.alternateView) { @@ -1333,13 +1774,56 @@ export class UnitLayer implements Layer { } } - const sprite = getColoredSprite( + return getColoredSprite( unit, this.theme, alternateViewColor ?? customTerritoryColor, alternateViewColor ?? undefined, ); + } + private computeSpriteRect( + unit: UnitView, + x: number, + y: number, + roundCoords: boolean, + customTerritoryColor?: Colord, + ): MoverSpriteRect { + if (this.isSmallMaskShip(unit)) { + const { x: outX, y: outY } = this.smallShipTopLeft(x, y, roundCoords); + const pad = 1; + return { + x: outX - pad, + y: outY - pad, + w: SMALL_SHIP_MASK_SIZE + pad * 2, + h: SMALL_SHIP_MASK_SIZE + pad * 2, + }; + } + + const sprite = this.resolveSprite(unit, customTerritoryColor); + const width = (sprite as { width: number }).width; + const height = (sprite as { height: number }).height; + const drawX = x - width / 2; + const drawY = y - height / 2; + const outX = roundCoords ? Math.round(drawX) : drawX; + const outY = roundCoords ? Math.round(drawY) : drawY; + const pad = 1; + return { + x: outX - pad, + y: outY - pad, + w: width + pad * 2, + h: width + pad * 2, + }; + } + + private drawSpriteAt( + unit: UnitView, + x: number, + y: number, + ctx: CanvasRenderingContext2D = this.context, + roundCoords: boolean = true, + customTerritoryColor?: Colord, + ): MoverSpriteRect | null { if (!unit.isActive()) { return null; } @@ -1350,21 +1834,76 @@ export class UnitLayer implements Layer { ctx.globalAlpha = 0.5; } + if (this.isSmallMaskShip(unit)) { + const mask = this.smallShipMask(unit); + const { territory, border } = this.resolveSmallShipMaskColors( + unit, + customTerritoryColor, + ); + const { x: outX, y: outY } = this.smallShipTopLeft(x, y, roundCoords); + + const centerToken = mask[2][2]; + const crossColor = centerToken === "T" ? territory : border; + + // Draw the center cross with 2 rectangles instead of 5 single pixels. + ctx.fillStyle = crossColor.toRgbString(); + ctx.fillRect(outX + 1, outY + 2, 3, 1); + ctx.fillRect(outX + 2, outY + 1, 1, 3); + + // Draw remaining ring pixels from the mask. + for (let row = 0; row < SMALL_SHIP_MASK_SIZE; row++) { + const line = mask[row]; + for (let col = 0; col < SMALL_SHIP_MASK_SIZE; col++) { + if (this.isSmallShipCrossCell(col, row)) { + continue; + } + const cellType = line[col]; + if (cellType === ".") { + continue; + } + ctx.fillStyle = + cellType === "T" ? territory.toRgbString() : border.toRgbString(); + ctx.fillRect(outX + col, outY + row, 1, 1); + } + } + + ctx.restore(); + + return this.computeSpriteRect( + unit, + x, + y, + roundCoords, + customTerritoryColor, + ); + } + + const sprite = this.resolveSprite(unit, customTerritoryColor) as { + width: number; + height: number; + }; + const drawX = x - sprite.width / 2; const drawY = y - sprite.height / 2; const outX = roundCoords ? Math.round(drawX) : drawX; const outY = roundCoords ? Math.round(drawY) : drawY; - ctx.drawImage(sprite, outX, outY, sprite.width, sprite.width); + ctx.drawImage( + sprite as CanvasImageSource, + outX, + outY, + sprite.width, + sprite.width, + ); ctx.restore(); - const pad = 1; - return { - x: outX - pad, - y: outY - pad, - w: sprite.width + pad * 2, - h: sprite.width + pad * 2, - }; + return this.computeSpriteRect( + unit, + x, + y, + roundCoords, + customTerritoryColor, + ); } private drawSprite(unit: UnitView, customTerritoryColor?: Colord) { @@ -1377,4 +1916,64 @@ export class UnitLayer implements Layer { customTerritoryColor, ); } + + private isSmallMaskShip(unit: UnitView): boolean { + const type = unit.type(); + return type === UnitType.TransportShip || type === UnitType.TradeShip; + } + + private smallShipMask(unit: UnitView): readonly string[] { + return unit.type() === UnitType.TransportShip + ? TRANSPORT_SHIP_MASK + : TRADE_SHIP_MASK; + } + + private smallShipTopLeft( + x: number, + y: number, + roundCoords: boolean, + ): { x: number; y: number } { + const drawX = x - SMALL_SHIP_MASK_SIZE / 2; + const drawY = y - SMALL_SHIP_MASK_SIZE / 2; + return { + x: roundCoords ? Math.round(drawX) : drawX, + y: roundCoords ? Math.round(drawY) : drawY, + }; + } + + private isSmallShipCrossCell(col: number, row: number): boolean { + return ( + (row === 2 && col >= 1 && col <= 3) || (col === 2 && row >= 1 && row <= 3) + ); + } + + private resolveSmallShipMaskColors( + unit: UnitView, + customTerritoryColor?: Colord, + ): { territory: Colord; border: Colord } { + if (this.alternateView) { + const rel = this.relationshipForAlternateView(unit); + switch (rel) { + case Relationship.Self: + return { + territory: this.theme.selfColor(), + border: this.theme.selfColor(), + }; + case Relationship.Ally: + return { + territory: this.theme.allyColor(), + border: this.theme.allyColor(), + }; + case Relationship.Enemy: + return { + territory: this.theme.enemyColor(), + border: this.theme.enemyColor(), + }; + } + } + return { + territory: customTerritoryColor ?? unit.owner().territoryColor(), + border: unit.owner().borderColor(), + }; + } }