diff --git a/TODO.txt b/TODO.txt index d17d7f272..d41f42b41 100644 --- a/TODO.txt +++ b/TODO.txt @@ -172,11 +172,21 @@ * rewrite EventsDisplay DONE 11/1/2024 * update Mena NPC locations DONE 11/1/2024 * create build menu DONE 11/3/2024 -* add gold -* add troop/worker slider +* add gold DONE 11/4/2024 +* add troop/worker slider DONE 11/4/2024 +* create Unit layer DONE 11/9/2024 +* create Unit interface DONE 11/10/2024 +* add destroyer DONE 11/12/2024 +* add ports DONE 11/14/2024 +* destroyer spawn from port DONE 11/14/2024 +* create trade routes DONE 11/15/2024 +* add trade ship DONE 11/15/2024 +* trade ship gives gold when completes route DONE 11/15/2024 +* add missile silo +* nuke spawns from missile silo +* destroyer can capture trade ships * add battleship * NPC has relations -* fix name rendering * use twitter emojis * private game shows how many players joined * optimize sendBoat function diff --git a/resources/images/AnchorIcon.png b/resources/images/AnchorIcon.png new file mode 100644 index 000000000..78bdfe73e Binary files /dev/null and b/resources/images/AnchorIcon.png differ diff --git a/resources/images/Destroyer.svg b/resources/images/Destroyer.svg new file mode 100644 index 000000000..bebcde38f --- /dev/null +++ b/resources/images/Destroyer.svg @@ -0,0 +1,47 @@ + + + + + + diff --git a/resources/images/DestroyerIconWhite.svg b/resources/images/DestroyerIconWhite.svg new file mode 100644 index 000000000..6bad21f6a --- /dev/null +++ b/resources/images/DestroyerIconWhite.svg @@ -0,0 +1,59 @@ + + + + + + + + + diff --git a/resources/images/PortIcon.svg b/resources/images/PortIcon.svg new file mode 100644 index 000000000..b3e417cd6 --- /dev/null +++ b/resources/images/PortIcon.svg @@ -0,0 +1,40 @@ + + diff --git a/src/client/ClientGame.ts b/src/client/ClientGame.ts index d4663178a..d307749be 100644 --- a/src/client/ClientGame.ts +++ b/src/client/ClientGame.ts @@ -1,5 +1,5 @@ import { Executor } from "../core/execution/ExecutionManager"; -import { Cell, MutableGame, PlayerEvent, PlayerID, MutablePlayer, TileEvent, Player, Game, BoatEvent, Tile, PlayerType, GameMap, Difficulty } from "../core/game/Game"; +import { Cell, MutableGame, PlayerEvent, PlayerID, MutablePlayer, TileEvent, Player, Game, UnitEvent, Tile, PlayerType, GameMap, Difficulty } from "../core/game/Game"; import { createGame } from "../core/game/GameImpl"; import { EventBus } from "../core/EventBus"; import { Config, getConfig } from "../core/configuration/Config"; diff --git a/src/client/Transport.ts b/src/client/Transport.ts index fe969c6af..11ac917b9 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -1,7 +1,7 @@ import { Config } from "../core/configuration/Config" import { EventBus, GameEvent } from "../core/EventBus" -import { AllianceRequest, AllPlayers, Cell, Player, PlayerID, PlayerType } from "../core/game/Game" -import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeaveMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema } from "../core/Schemas" +import { AllianceRequest, AllPlayers, Cell, BuildItem, Player, PlayerID, PlayerType, Tile, UnitType } from "../core/game/Game" +import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeaveMessageSchema, BuildUnitIntentSchema, GameID, Intent, ServerMessage, ServerMessageSchema } from "../core/Schemas" import { LocalServer } from "./LocalServer" @@ -47,6 +47,13 @@ export class SendBoatAttackIntentEvent implements GameEvent { ) { } } +export class BuildUnitIntentEvent implements GameEvent { + constructor( + public readonly unit: UnitType, + public readonly cell: Cell, + ) { } +} + export class SendTargetPlayerIntentEvent implements GameEvent { constructor( public readonly targetID: PlayerID, @@ -115,6 +122,7 @@ export class Transport { this.eventBus.on(SendDonateIntentEvent, (e) => this.onSendDonateIntent(e)) this.eventBus.on(SendNukeIntentEvent, (e) => this.onSendNukeIntent(e)) this.eventBus.on(SendSetTargetTroopRatioEvent, (e) => this.onSendSetTargetTroopRatioEvent(e)) + this.eventBus.on(BuildUnitIntentEvent, (e) => this.onCreateDestroyerIntent(e)) } connect(onconnect: () => void, onmessage: (message: ServerMessage) => void) { @@ -314,6 +322,17 @@ export class Transport { }) } + private onCreateDestroyerIntent(event: BuildUnitIntentEvent) { + this.sendIntent({ + type: "build_unit", + clientID: this.clientID, + player: this.playerID, + unit: event.unit, + x: event.cell.x, + y: event.cell.y, + }) + } + private sendIntent(intent: Intent) { if (this.isLocal || this.socket.readyState === WebSocket.OPEN) { const msg = ClientIntentMessageSchema.parse({ diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 579de4a00..7a248fd5c 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -14,6 +14,9 @@ import { Leaderboard } from "./layers/Leaderboard"; import { ControlPanel } from "./layers/ControlPanel"; import { UIState } from "./UIState"; import { BuildMenu } from "./layers/radial/BuildMenu"; +import { UnitLayer } from "./layers/UnitLayer"; +import { BuildValidator } from "../../core/game/BuildValidator"; +import { StructureLayer } from "./layers/StructureLayer"; export function createRenderer(canvas: HTMLCanvasElement, game: Game, eventBus: EventBus, clientID: ClientID): GameRenderer { @@ -32,7 +35,7 @@ export function createRenderer(canvas: HTMLCanvasElement, game: Game, eventBus: } buildMenu.game = game buildMenu.eventBus = eventBus - buildMenu.init() + buildMenu.buildValidator = new BuildValidator(game) const leaderboard = document.querySelector('leader-board') as Leaderboard; if (!emojiTable || !(leaderboard instanceof Leaderboard)) { @@ -61,6 +64,8 @@ export function createRenderer(canvas: HTMLCanvasElement, game: Game, eventBus: const layers: Layer[] = [ new TerrainLayer(game), new TerritoryLayer(game, eventBus), + new StructureLayer(game, eventBus), + new UnitLayer(game, eventBus), new NameLayer(game, game.config().theme(), transformHandler, clientID), new UILayer(eventBus, game, clientID, transformHandler), eventsDisplay, diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts new file mode 100644 index 000000000..94f904a7c --- /dev/null +++ b/src/client/graphics/layers/StructureLayer.ts @@ -0,0 +1,149 @@ +import { Colord } from "colord"; +import { Theme } from "../../../core/configuration/Config"; +import { Unit, UnitEvent, Cell, Game, Tile, UnitType } from "../../../core/game/Game"; +import { bfs, dist, euclDist } from "../../../core/Util"; +import { Layer } from "./Layer"; +import { EventBus } from "../../../core/EventBus"; + +import anchorIcon from '../../../../resources/images/AnchorIcon.png'; + +export class StructureLayer implements Layer { + private canvas: HTMLCanvasElement; + private context: CanvasRenderingContext2D; + private imageData: ImageData; + private anchorImage: HTMLImageElement; + private anchorImageLoaded: boolean = false; + + + private theme: Theme = null; + + constructor(private game: Game, private eventBus: EventBus) { + this.theme = game.config().theme(); + this.loadAnchorImage(); + } + + private loadAnchorImage() { + this.anchorImage = new Image(); + this.anchorImage.onload = () => { + this.anchorImageLoaded = true; + }; + this.anchorImage.src = anchorIcon; + } + + shouldTransform(): boolean { + return true; + } + + tick() { + } + + init(game: Game) { + this.canvas = document.createElement('canvas'); + this.context = this.canvas.getContext("2d"); + + this.imageData = this.context.getImageData(0, 0, this.game.width(), this.game.height()); + this.canvas.width = this.game.width(); + this.canvas.height = this.game.height(); + this.context.putImageData(this.imageData, 0, 0); + this.initImageData(); + + this.eventBus.on(UnitEvent, e => this.onUnitEvent(e)); + } + + initImageData() { + this.game.forEachTile((tile) => { + const index = (tile.cell().y * this.game.width()) + tile.cell().x; + const offset = index * 4; + this.imageData.data[offset + 3] = 0; + }); + } + + renderLayer(context: CanvasRenderingContext2D) { + this.context.putImageData(this.imageData, 0, 0); + context.drawImage( + this.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height() + ); + } + + private handlePortEvent(event: UnitEvent) { + if (!this.anchorImageLoaded) return; + + bfs(event.unit.tile(), euclDist(event.unit.tile(), 8)) + .forEach(t => this.clearCell(t.cell())); + + if (!event.unit.isActive()) { + return + } + // Create a temporary canvas to process the anchor icon + const tempCanvas = document.createElement('canvas'); + const tempContext = tempCanvas.getContext('2d'); + tempCanvas.width = this.anchorImage.width; + tempCanvas.height = this.anchorImage.height; + + // Draw the anchor icon to the temporary canvas + tempContext.drawImage(this.anchorImage, 0, 0); + const iconData = tempContext.getImageData(0, 0, tempCanvas.width, tempCanvas.height); + + // Calculate position to center the icon on the port + const cell = event.unit.tile().cell(); + const startX = cell.x - Math.floor(tempCanvas.width / 2); + const startY = cell.y - Math.floor(tempCanvas.height / 2); + + bfs(event.unit.tile(), euclDist(event.unit.tile(), 8)) + .forEach(t => this.paintCell(t.cell(), this.theme.borderColor(event.unit.owner().info()), 255)); + + bfs(event.unit.tile(), euclDist(event.unit.tile(), 6)) + .forEach(t => this.paintCell(t.cell(), this.theme.territoryColor(event.unit.owner().info()), 255)); + // Process each pixel of the icon + for (let y = 0; y < tempCanvas.height; y++) { + for (let x = 0; x < tempCanvas.width; x++) { + const iconIndex = (y * tempCanvas.width + x) * 4; + const alpha = iconData.data[iconIndex + 3]; + + if (alpha > 0) { // Only process non-transparent pixels + const targetX = startX + x; + const targetY = startY + y; + + // Check if the target pixel is within the game bounds + if (targetX >= 0 && targetX < this.game.width() && + targetY >= 0 && targetY < this.game.height()) { + + // Color the pixel using the unit owner's colors + this.paintCell( + new Cell(targetX, targetY), + this.theme.borderColor(event.unit.owner().info()), + alpha + ); + } + } + } + } + } + + onUnitEvent(event: UnitEvent) { + switch (event.unit.type()) { + case UnitType.Port: + this.handlePortEvent(event); + break; + } + } + + paintCell(cell: Cell, color: Colord, alpha: number) { + const index = (cell.y * this.game.width()) + cell.x; + const offset = index * 4; + this.imageData.data[offset] = color.rgba.r; + this.imageData.data[offset + 1] = color.rgba.g; + this.imageData.data[offset + 2] = color.rgba.b; + this.imageData.data[offset + 3] = alpha; + } + + clearCell(cell: Cell) { + const index = (cell.y * this.game.width()) + cell.x; + const offset = index * 4; + this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) + } +} \ No newline at end of file diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 85d76d87a..c6dfd6c31 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -1,33 +1,33 @@ -import {PriorityQueue} from "@datastructures-js/priority-queue"; -import {Boat, BoatEvent, Cell, Game, Player, Tile, TileEvent} from "../../../core/game/Game"; -import {PseudoRandom} from "../../../core/PseudoRandom"; -import {Colord} from "colord"; -import {bfs, dist} from "../../../core/Util"; -import {Theme} from "../../../core/configuration/Config"; -import {Layer} from "./Layer"; -import {TransformHandler} from "../TransformHandler"; -import {EventBus} from "../../../core/EventBus"; +import { PriorityQueue } from "@datastructures-js/priority-queue"; +import { Cell, Game, Player, Tile, TileEvent } from "../../../core/game/Game"; +import { PseudoRandom } from "../../../core/PseudoRandom"; +import { Colord } from "colord"; +import { bfs, dist } from "../../../core/Util"; +import { Theme } from "../../../core/configuration/Config"; +import { Layer } from "./Layer"; +import { TransformHandler } from "../TransformHandler"; +import { EventBus } from "../../../core/EventBus"; export class TerritoryLayer implements Layer { private canvas: HTMLCanvasElement private context: CanvasRenderingContext2D private imageData: ImageData - private tileToRenderQueue: PriorityQueue<{tileEvent: TileEvent, lastUpdate: number}> = new PriorityQueue((a, b) => {return a.lastUpdate - b.lastUpdate}) + private tileToRenderQueue: PriorityQueue<{ tileEvent: TileEvent, lastUpdate: number }> = new PriorityQueue((a, b) => { return a.lastUpdate - b.lastUpdate }) private random = new PseudoRandom(123) private theme: Theme = null - private boatToTrail = new Map>() constructor(private game: Game, eventBus: EventBus) { this.theme = game.config().theme() eventBus.on(TileEvent, e => this.tileUpdate(e)) - eventBus.on(BoatEvent, e => this.boatEvent(e)) } + shouldTransform(): boolean { return true } + tick() { } @@ -62,31 +62,6 @@ export class TerritoryLayer implements Layer { ) } - boatEvent(event: BoatEvent) { - if (!this.boatToTrail.has(event.boat)) { - this.boatToTrail.set(event.boat, new Set()) - } - const trail = this.boatToTrail.get(event.boat) - trail.add(event.oldTile) - bfs(event.oldTile, dist(event.oldTile, 3)).forEach(t => { - this.paintTerritory(t) - }) - if (event.boat.isActive()) { - bfs(event.boat.tile(), dist(event.boat.tile(), 4)).forEach( - t => { - if (trail.has(t)) { - this.paintCell(t.cell(), this.theme.territoryColor(event.boat.owner().info()), 150) - } - } - ) - bfs(event.boat.tile(), dist(event.boat.tile(), 2)).forEach(t => this.paintCell(t.cell(), this.theme.borderColor(event.boat.owner().info()), 255)) - bfs(event.boat.tile(), dist(event.boat.tile(), 1)).forEach(t => this.paintCell(t.cell(), this.theme.territoryColor(event.boat.owner().info()), 180)) - } else { - trail.forEach(t => this.paintTerritory(t)) - this.boatToTrail.delete(event.boat) - } - } - renderTerritory() { let numToRender = Math.floor(this.tileToRenderQueue.size() / 10) if (numToRender == 0) { @@ -138,6 +113,6 @@ export class TerritoryLayer implements Layer { } tileUpdate(event: TileEvent) { - this.tileToRenderQueue.push({tileEvent: event, lastUpdate: this.game.ticks() + this.random.nextFloat(0, .5)}) + this.tileToRenderQueue.push({ tileEvent: event, lastUpdate: this.game.ticks() + this.random.nextFloat(0, .5) }) } } \ No newline at end of file diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts new file mode 100644 index 000000000..c6b93aa69 --- /dev/null +++ b/src/client/graphics/layers/UnitLayer.ts @@ -0,0 +1,147 @@ +import { Colord } from "colord"; +import { Theme } from "../../../core/configuration/Config"; +import { Unit, UnitEvent, Cell, Game, Tile, UnitType } from "../../../core/game/Game"; +import { bfs, dist, euclDist } from "../../../core/Util"; +import { Layer } from "./Layer"; +import { EventBus } from "../../../core/EventBus"; + +import anchorIcon from '../../../../resources/images/AnchorIcon.png'; + +export class UnitLayer implements Layer { + private canvas: HTMLCanvasElement; + private context: CanvasRenderingContext2D; + private imageData: ImageData; + private anchorImage: HTMLImageElement; + private anchorImageLoaded: boolean = false; + + private boatToTrail = new Map>(); + + private theme: Theme = null; + + constructor(private game: Game, private eventBus: EventBus) { + this.theme = game.config().theme(); + this.loadAnchorImage(); + } + + private loadAnchorImage() { + this.anchorImage = new Image(); + this.anchorImage.onload = () => { + this.anchorImageLoaded = true; + }; + this.anchorImage.src = anchorIcon; + } + + shouldTransform(): boolean { + return true; + } + + tick() { + } + + init(game: Game) { + this.canvas = document.createElement('canvas'); + this.context = this.canvas.getContext("2d"); + + this.imageData = this.context.getImageData(0, 0, this.game.width(), this.game.height()); + this.canvas.width = this.game.width(); + this.canvas.height = this.game.height(); + this.context.putImageData(this.imageData, 0, 0); + this.initImageData(); + + this.eventBus.on(UnitEvent, e => this.onUnitEvent(e)); + } + + initImageData() { + this.game.forEachTile((tile) => { + const index = (tile.cell().y * this.game.width()) + tile.cell().x; + const offset = index * 4; + this.imageData.data[offset + 3] = 0; + }); + } + + renderLayer(context: CanvasRenderingContext2D) { + this.context.putImageData(this.imageData, 0, 0); + context.drawImage( + this.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height() + ); + } + + onUnitEvent(event: UnitEvent) { + switch (event.unit.type()) { + case UnitType.TransportShip: + this.handleBoatEvent(event); + break; + case UnitType.Destroyer: + this.handleDestroyerEvent(event); + break; + case UnitType.TradeShip: + this.handleTradeShipEvent(event) + } + } + + private handleDestroyerEvent(event: UnitEvent) { + bfs(event.oldTile, euclDist(event.oldTile, 3)).forEach(t => { + this.clearCell(t.cell()); + }); + bfs(event.unit.tile(), euclDist(event.unit.tile(), 3)) + .forEach(t => this.paintCell(t.cell(), this.theme.borderColor(event.unit.owner().info()), 255)); + bfs(event.unit.tile(), euclDist(event.unit.tile(), 2)) + .forEach(t => this.paintCell(t.cell(), this.theme.territoryColor(event.unit.owner().info()), 180)); + } + + private handleTradeShipEvent(event: UnitEvent) { + bfs(event.oldTile, euclDist(event.oldTile, 1)).forEach(t => { + this.clearCell(t.cell()); + }); + if (event.unit.isActive()) { + bfs(event.unit.tile(), euclDist(event.unit.tile(), 1)) + .forEach(t => this.paintCell(t.cell(), this.theme.borderColor(event.unit.owner().info()), 255)); + } + } + + private handleBoatEvent(event: UnitEvent) { + if (!this.boatToTrail.has(event.unit)) { + this.boatToTrail.set(event.unit, new Set()); + } + const trail = this.boatToTrail.get(event.unit); + trail.add(event.oldTile); + bfs(event.oldTile, dist(event.oldTile, 3)).forEach(t => { + this.clearCell(t.cell()); + }); + if (event.unit.isActive()) { + bfs(event.unit.tile(), dist(event.unit.tile(), 4)).forEach( + t => { + if (trail.has(t)) { + this.paintCell(t.cell(), this.theme.territoryColor(event.unit.owner().info()), 150); + } + } + ); + bfs(event.unit.tile(), dist(event.unit.tile(), 2)) + .forEach(t => this.paintCell(t.cell(), this.theme.borderColor(event.unit.owner().info()), 255)); + bfs(event.unit.tile(), dist(event.unit.tile(), 1)) + .forEach(t => this.paintCell(t.cell(), this.theme.territoryColor(event.unit.owner().info()), 180)); + } else { + trail.forEach(t => this.clearCell(t.cell())); + this.boatToTrail.delete(event.unit); + } + } + + paintCell(cell: Cell, color: Colord, alpha: number) { + const index = (cell.y * this.game.width()) + cell.x; + const offset = index * 4; + this.imageData.data[offset] = color.rgba.r; + this.imageData.data[offset + 1] = color.rgba.g; + this.imageData.data[offset + 2] = color.rgba.b; + this.imageData.data[offset + 3] = alpha; + } + + clearCell(cell: Cell) { + const index = (cell.y * this.game.width()) + cell.x; + const offset = index * 4; + this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) + } +} \ No newline at end of file diff --git a/src/client/graphics/layers/radial/BuildMenu.ts b/src/client/graphics/layers/radial/BuildMenu.ts index 5ff17f655..4cb243426 100644 --- a/src/client/graphics/layers/radial/BuildMenu.ts +++ b/src/client/graphics/layers/radial/BuildMenu.ts @@ -1,22 +1,26 @@ import { LitElement, html, css } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { EventBus } from '../../../../core/EventBus'; -import { Cell, Game, Item, Items, Player } from '../../../../core/game/Game'; -import { SendNukeIntentEvent } from '../../../Transport'; +import { Cell, Game, BuildItem, BuildItems, Player, UnitType } from '../../../../core/game/Game'; +import { BuildUnitIntentEvent, SendNukeIntentEvent } from '../../../Transport'; import nukeIcon from '../../../../../resources/images/NukeIconWhite.svg'; +import destroyerIcon from '../../../../../resources/images/DestroyerIconWhite.svg'; import goldCoinIcon from '../../../../../resources/images/GoldCoinIcon.svg'; +import portIcon from '../../../../../resources/images/PortIcon.svg'; import { renderNumber } from '../../Utils'; +import { BuildValidator } from '../../../../core/game/BuildValidator'; import { ContextMenuEvent } from '../../../InputHandler'; -interface BuildItem { - item: Item +interface BuildItemDisplay { + item: BuildItem icon: string; } -const buildTable: BuildItem[][] = [ +const buildTable: BuildItemDisplay[][] = [ [ - { item: Items.Nuke, icon: nukeIcon }, - // { id: 'battleship', name: 'Battleship', icon: '🚢', cost: 500, buildTime: 20 } + { item: BuildItems.Nuke, icon: nukeIcon }, + { item: BuildItems.Destroyer, icon: destroyerIcon }, + { item: BuildItems.Port, icon: portIcon } ] ]; @@ -24,6 +28,7 @@ const buildTable: BuildItem[][] = [ export class BuildMenu extends LitElement { public game: Game; public eventBus: EventBus; + public buildValidator: BuildValidator; private myPlayer: Player; private clickedCell: Cell; @@ -142,12 +147,25 @@ export class BuildMenu extends LitElement { @state() private _hidden = true; - private canAfford(item: BuildItem): boolean { - return this.myPlayer && this.myPlayer.gold() >= item.item.cost; + private canBuild(item: BuildItemDisplay): boolean { + if (this.myPlayer == null) { + return false + } + return this.buildValidator.canBuild(this.myPlayer, this.game.tile(this.clickedCell), item.item) } - public onBuildSelected: (item: BuildItem) => void = () => { - this.eventBus.emit(new SendNukeIntentEvent(this.myPlayer, this.clickedCell, null)) + public onBuildSelected = (item: BuildItemDisplay) => { + switch (item.item) { + case BuildItems.Nuke: + this.eventBus.emit(new SendNukeIntentEvent(this.myPlayer, this.clickedCell, null)) + break + case BuildItems.Destroyer: + this.eventBus.emit(new BuildUnitIntentEvent(UnitType.Destroyer, this.clickedCell)) + break + case BuildItems.Port: + this.eventBus.emit(new BuildUnitIntentEvent(UnitType.Port, this.clickedCell)) + break + } this.hideMenu() }; @@ -160,11 +178,11 @@ export class BuildMenu extends LitElement {