diff --git a/resources/lang/de.json b/resources/lang/de.json index cdedccd6a..620735a1c 100644 --- a/resources/lang/de.json +++ b/resources/lang/de.json @@ -232,7 +232,8 @@ }, "game_mode": { "ffa": "Free for All", - "teams": "Teams" + "teams": "Teams", + "fog_of_war": "Kriegsnebel" }, "select_lang": { "title": "Sprache auswählen" diff --git a/resources/lang/en.json b/resources/lang/en.json index c31101bc9..bca4aeb4a 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -272,7 +272,8 @@ }, "game_mode": { "ffa": "Free for All", - "teams": "Teams" + "teams": "Teams", + "fog_of_war": "Fog of War" }, "select_lang": { "title": "Select Language" diff --git a/resources/lang/es.json b/resources/lang/es.json index 93c877be5..6ec7ec95f 100644 --- a/resources/lang/es.json +++ b/resources/lang/es.json @@ -202,7 +202,8 @@ }, "game_mode": { "ffa": "Todos contra todos", - "teams": "Equipos" + "teams": "Equipos", + "fog_of_war": "Niebla de guerra" }, "select_lang": { "title": "Selecciona idioma" diff --git a/resources/lang/fr.json b/resources/lang/fr.json index fda7077c5..0e8b44319 100644 --- a/resources/lang/fr.json +++ b/resources/lang/fr.json @@ -260,7 +260,8 @@ }, "game_mode": { "ffa": "Chacun pour soi", - "teams": "Équipes" + "teams": "Équipes", + "fog_of_war": "Brouillard de guerre" }, "select_lang": { "title": "Sélectionner une langue" diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 087cad4f0..8596dea04 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -260,7 +260,8 @@ }, "game_mode": { "ffa": "バトルロワイヤル", - "teams": "チーム" + "teams": "チーム", + "fog_of_war": "戦争の霧" }, "select_lang": { "title": "言語を選択" diff --git a/resources/lang/pt-BR.json b/resources/lang/pt-BR.json index c615438e6..0cc715ee9 100644 --- a/resources/lang/pt-BR.json +++ b/resources/lang/pt-BR.json @@ -171,7 +171,8 @@ }, "game_mode": { "ffa": "Free for All", - "teams": "Equipes" + "teams": "Equipes", + "fog_of_war": "Nevoeiro de Guerra" }, "select_lang": { "title": "Selecionar idioma" diff --git a/resources/lang/ru.json b/resources/lang/ru.json index a71adfafc..ae709a45e 100644 --- a/resources/lang/ru.json +++ b/resources/lang/ru.json @@ -260,7 +260,8 @@ }, "game_mode": { "ffa": "Каждый против каждого (FFA)", - "teams": "Команды" + "teams": "Команды", + "fog_of_war": "Туман войны" }, "select_lang": { "title": "Выберите язык" diff --git a/resources/lang/zh-CN.json b/resources/lang/zh-CN.json index 2e748efa2..76bb922ee 100644 --- a/resources/lang/zh-CN.json +++ b/resources/lang/zh-CN.json @@ -257,7 +257,8 @@ }, "game_mode": { "ffa": "混战", - "teams": "团队" + "teams": "团队", + "fog_of_war": "战争迷雾" }, "select_lang": { "title": "选择语言" diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index f4f6f119e..c58640330 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -12,7 +12,7 @@ import { import { createPartialGameRecord, replacer } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; import { getConfig } from "../core/configuration/ConfigLoader"; -import { PlayerActions, UnitType } from "../core/game/Game"; +import { GameMode, PlayerActions, UnitType } from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { GameMapLoader } from "../core/game/GameMapLoader"; import { @@ -270,6 +270,15 @@ export class ClientGameRunner { this.renderer.initialize(); this.input.initialize(); + + // Pass the game reference to the InputHandler + this.input.setGame(this.gameView); + + // Pass the FogOfWarLayer reference to the InputHandler (if available) + if (this.renderer && this.renderer.fogOfWarLayer) { + this.input.setFogOfWarLayer(this.renderer.fogOfWarLayer); + } + this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => { if (this.lobby.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); @@ -386,9 +395,10 @@ export class ClientGameRunner { } private inputEvent(event: MouseUpEvent) { - if (!this.isActive || this.renderer.uiState.ghostStructure !== null) { + if (!this.isActive) { return; } + const cell = this.renderer.transformHandler.screenToWorldCoordinates( event.x, event.y, @@ -398,6 +408,24 @@ export class ClientGameRunner { } console.log(`clicked cell ${cell}`); const tile = this.gameView.ref(cell.x, cell.y); + + // Check if we are in Fog of War mode and if the position is completely fogged (fog = 1) + // Allow attacks even in areas with fog = 1 + let isFoggedArea = false; + if (this.renderer && this.renderer.fogOfWarLayer && + this.gameView.config().gameConfig().gameMode === GameMode.FogOfWar) { + const tileX = this.gameView.x(tile); + const tileY = this.gameView.y(tile); + const idx = tileY * this.gameView.width() + tileX; + const fogValue = this.renderer.fogOfWarLayer.getFogValueAt(idx); + + // If fog is completely fogged (value 1), mark as fogged area + if (fogValue >= 1.0) { + isFoggedArea = true; + } + } + + // Allow spawn in all modes, not just Fog of War mode if ( this.gameView.isLand(tile) && !this.gameView.hasOwner(tile) && @@ -423,7 +451,7 @@ export class ClientGameRunner { this.myPlayer.troops() * this.renderer.uiState.attackRatio, ), ); - } else if (this.canAutoBoat(actions, tile)) { + } else if (this.canBoatAttack(actions, tile)) { this.sendBoatAttackIntent(tile); } @@ -519,7 +547,7 @@ export class ClientGameRunner { } this.myPlayer.actions(tile).then((actions) => { - if (this.canBoatAttack(actions) !== false) { + if (!actions.canAttack && this.canBoatAttack(actions, tile)) { this.sendBoatAttackIntent(tile); } }); @@ -567,7 +595,7 @@ export class ClientGameRunner { return this.gameView.ref(cell.x, cell.y); } - private canBoatAttack(actions: PlayerActions): false | TileRef { + private canBoatAttack(actions: PlayerActions, tile: TileRef): boolean { const bu = actions.buildableUnits.find( (bu) => bu.type === UnitType.TransportShip, ); @@ -575,7 +603,11 @@ export class ClientGameRunner { console.warn(`no transport ship buildable units`); return false; } - return bu.canBuild; + return ( + bu.canBuild !== false && + this.shouldBoat(tile, bu.canBuild) && + this.gameView.isLand(tile) + ); } private sendBoatAttackIntent(tile: TileRef) { @@ -594,20 +626,16 @@ export class ClientGameRunner { }); } - private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean { - if (!this.gameView.isLand(tile)) return false; - - const canBuild = this.canBoatAttack(actions); - if (canBuild === false) return false; - + private shouldBoat(tile: TileRef, src: TileRef) { // TODO: Global enable flag // TODO: Global limit autoboat to nearby shore flag // if (!enableAutoBoat) return false; // if (!limitAutoBoatNear) return true; - const distanceSquared = this.gameView.euclideanDistSquared(tile, canBuild); + const distanceSquared = this.gameView.euclideanDistSquared(tile, src); const limit = 100; const limitSquared = limit * limit; - return distanceSquared < limitSquared; + if (distanceSquared > limitSquared) return false; + return true; } private onMouseMove(event: MouseMoveEvent) { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 37f954476..bcbfb4454 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -155,7 +155,7 @@ export class HostLobbyModal extends LitElement { xmlns="http://www.w3.org/2000/svg" > ` @@ -269,11 +269,19 @@ export class HostLobbyModal extends LitElement { ${translateText("game_mode.teams")} +
this.handleGameModeSelection(GameMode.FogOfWar)} + > +
+ ${translateText("game_mode.fog_of_war")} +
+
${ - this.gameMode === GameMode.FFA + this.gameMode === GameMode.FFA || this.gameMode === GameMode.FogOfWar ? "" : html` diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 686ea2e48..f87669ae4 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -1,5 +1,5 @@ import { EventBus, GameEvent } from "../core/EventBus"; -import { UnitType } from "../core/game/Game"; +import { GameMode, UnitType } from "../core/game/Game"; import { UnitView } from "../core/game/GameView"; import { UserSettings } from "../core/game/UserSettings"; import { UIState } from "./graphics/UIState"; @@ -138,6 +138,10 @@ export class InputHandler { private readonly ZOOM_SPEED = 10; private readonly userSettings: UserSettings = new UserSettings(); + + // Add reference to game and fogOfWarLayer to check the mode + private game: any = null; + private fogOfWarLayer: any = null; constructor( public uiState: UIState, @@ -145,6 +149,16 @@ export class InputHandler { private eventBus: EventBus, ) {} + // Method to set the reference to the game + public setGame(game: any) { + this.game = game; + } + + // Method to set the reference to the FogOfWarLayer + public setFogOfWarLayer(fogOfWarLayer: any) { + this.fogOfWarLayer = fogOfWarLayer; + } + initialize() { let saved: Record = {}; try { @@ -206,7 +220,9 @@ export class InputHandler { this.canvas.addEventListener( "wheel", (e) => { - this.onScroll(e); + if (!this.onTrackpadPan(e)) { + this.onScroll(e); + } this.onShiftScroll(e); e.preventDefault(); }, @@ -219,6 +235,16 @@ export class InputHandler { this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY)); } }); + + this.canvas.addEventListener("touchstart", (e) => this.onTouchStart(e), { + passive: false, + }); + this.canvas.addEventListener("touchmove", (e) => this.onTouchMove(e), { + passive: false, + }); + this.canvas.addEventListener("touchend", (e) => this.onTouchEnd(e), { + passive: false, + }); this.pointers.clear(); this.moveInterval = setInterval(() => { @@ -442,7 +468,7 @@ export class InputHandler { } } - onPointerUp(event: PointerEvent) { + private onPointerUp(event: PointerEvent) { if (event.button === 1) { event.preventDefault(); return; @@ -454,7 +480,38 @@ export class InputHandler { this.pointerDown = false; this.pointers.clear(); + // Check if we are in Fog of War mode before showing the build menu if (this.isModifierKeyPressed(event)) { + // If in Fog of War mode, check the fog value at the clicked position + if (this.game && this.game.config && this.fogOfWarLayer) { + // Check if the game mode is Fog of War (correct comparison) + const gameMode = this.game.config().gameConfig().gameMode; + if (gameMode === GameMode.FogOfWar) { + // Convert screen coordinates to world coordinates + const rect = this.canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + // Convert to world coordinates + if (this.game.renderer && this.game.renderer.transformHandler) { + const worldCoords = this.game.renderer.transformHandler.screenToWorldCoordinates(x, y); + + // Check if coordinates are valid + if (this.game.isValidCoord && this.game.isValidCoord(worldCoords.x, worldCoords.y)) { + const tileRef = this.game.ref(worldCoords.x, worldCoords.y); + const tileX = this.game.x(tileRef); + const tileY = this.game.y(tileRef); + const idx = tileY * this.game.width() + tileX; + const fogValue = this.fogOfWarLayer.getFogValueAt(idx); + + // If fog is completely fogged (value 1), don't show the menu + if (fogValue >= 1.0) { + return; + } + } + } + } + } this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY)); return; } @@ -499,6 +556,27 @@ export class InputHandler { } } + private onTrackpadPan(event: WheelEvent): boolean { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + return false; + } + + const isTrackpadPan = event.deltaMode === 0 && event.deltaX !== 0; + + if (!isTrackpadPan) { + return false; + } + + const panSensitivity = 1.0; + const deltaX = -event.deltaX * panSensitivity; + const deltaY = -event.deltaY * panSensitivity; + + if (Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5) { + this.eventBus.emit(new DragEvent(deltaX, deltaY)); + } + return true; + } + private onPointerMove(event: PointerEvent) { if (event.button === 1) { event.preventDefault(); @@ -547,6 +625,47 @@ export class InputHandler { this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); } + private onTouchStart(event: TouchEvent) { + if (event.touches.length === 2) { + event.preventDefault(); + // Solve screen jittering problem + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + this.lastPointerX = (touch1.clientX + touch2.clientX) / 2; + this.lastPointerY = (touch1.clientY + touch2.clientY) / 2; + } + } + + private onTouchMove(event: TouchEvent) { + if (event.touches.length === 2) { + event.preventDefault(); + + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + const centerX = (touch1.clientX + touch2.clientX) / 2; + const centerY = (touch1.clientY + touch2.clientY) / 2; + + if (this.lastPointerX !== 0 && this.lastPointerY !== 0) { + const deltaX = centerX - this.lastPointerX; + const deltaY = centerY - this.lastPointerY; + + if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) { + this.eventBus.emit(new DragEvent(deltaX, deltaY)); + } + } + + this.lastPointerX = centerX; + this.lastPointerY = centerY; + } + } + + private onTouchEnd(event: TouchEvent) { + if (event.touches.length < 2) { + this.lastPointerX = 0; + this.lastPointerY = 0; + } + } + private getPinchDistance(): number { const pointerEvents = Array.from(this.pointers.values()); const dx = pointerEvents[0].clientX - pointerEvents[1].clientX; diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 01e62f928..385115ca8 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -181,10 +181,20 @@ export class SinglePlayerModal extends LitElement { ${translateText("game_mode.teams")} +
this.handleGameModeSelection(GameMode.FogOfWar)} + > +
+ ${translateText("game_mode.fog_of_war")} +
+
- ${this.gameMode === GameMode.FFA + ${this.gameMode === GameMode.FFA || this.gameMode === GameMode.FogOfWar ? "" : html` diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index dd0954980..f53164be6 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -14,6 +14,7 @@ import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; import { FPSDisplay } from "./layers/FPSDisplay"; import { FxLayer } from "./layers/FxLayer"; +import { FogOfWarLayer } from "./layers/FogOfWarLayer"; import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { GameRightSidebar } from "./layers/GameRightSidebar"; import { GutterAdModal } from "./layers/GutterAdModal"; @@ -87,6 +88,7 @@ export function createRenderer( console.error("GameLeftSidebar element not found in the DOM"); } gameLeftSidebar.game = game; + gameLeftSidebar.eventBus = eventBus; const teamStats = document.querySelector("team-stats") as TeamStats; if (!teamStats || !(teamStats instanceof TeamStats)) { @@ -230,6 +232,9 @@ export function createRenderer( } alertFrame.game = game; + const fogOfWarLayer = new FogOfWarLayer(game, transformHandler); + const nameLayer = new NameLayer(game, transformHandler, eventBus, fogOfWarLayer); + // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -242,26 +247,45 @@ export function createRenderer( new FxLayer(game), new UILayer(game, eventBus, transformHandler), new StructureIconsLayer(game, eventBus, uiState, transformHandler), - new NameLayer(game, transformHandler, eventBus), + nameLayer, eventsDisplay, chatDisplay, buildMenu, - new MainRadialMenu( - eventBus, - game, - transformHandler, - emojiTable as EmojiTable, - buildMenu, - uiState, - playerPanel, - ), + // Pass the NameLayer reference to the MainRadialMenu + (() => { + const mainRadialMenu = new MainRadialMenu( + eventBus, + game, + transformHandler, + emojiTable as EmojiTable, + buildMenu, + uiState, + playerPanel, + ); + mainRadialMenu.setFogOfWarLayer(fogOfWarLayer); + mainRadialMenu.setNameLayer(nameLayer); + return mainRadialMenu; + })(), new SpawnTimer(game, transformHandler), - leaderboard, + // Pass the FogOfWarLayer reference to the Leaderboard + (() => { + leaderboard.fogOfWarLayer = fogOfWarLayer; + return leaderboard; + })(), gameLeftSidebar, unitDisplay, - gameRightSidebar, + // Pass the FogOfWarLayer reference to GameRightSidebar + (() => { + gameRightSidebar.fogOfWarLayer = fogOfWarLayer; + return gameRightSidebar; + })(), controlPanel, - playerInfo, + // Pass the FogOfWarLayer and NameLayer references to PlayerInfoOverlay + (() => { + playerInfo.setFogOfWarLayer(fogOfWarLayer); + playerInfo.setNameLayer(nameLayer); + return playerInfo; + })(), winModal, replayPanel, settingsModal, @@ -273,6 +297,8 @@ export function createRenderer( gutterAdModal, alertFrame, fpsDisplay, + // Fog of War layer moved to the end to render on top of all other elements + fogOfWarLayer, ]; return new GameRenderer( @@ -302,6 +328,9 @@ export class GameRenderer { if (context === null) throw new Error("2d context not supported"); this.context = context; } + + // Add property for FogOfWarLayer + public fogOfWarLayer: any = null; initialize() { this.eventBus.on(RedrawGraphicsEvent, () => this.redraw()); diff --git a/src/client/graphics/TransformHandler.ts b/src/client/graphics/TransformHandler.ts index 47b569d5a..cfb8e7913 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/graphics/TransformHandler.ts @@ -7,6 +7,7 @@ import { GoToPositionEvent, GoToUnitEvent, } from "./layers/Leaderboard"; +import { FogOfWarLayer } from "./layers/FogOfWarLayer"; export const GOTO_INTERVAL_MS = 16; export const CAMERA_MAX_SPEED = 15; @@ -22,6 +23,9 @@ export class TransformHandler { private target: Cell | null; private intervalID: NodeJS.Timeout | null = null; private changed = false; + + // Adding reference to FogOfWarLayer + public fogOfWarLayer: FogOfWarLayer | null = null; constructor( private game: GameView, diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 46adc3367..20cbfe65f 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -15,6 +15,7 @@ import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; import { BuildableUnit, + GameMode, Gold, PlayerActions, UnitType, @@ -129,6 +130,9 @@ export class BuildMenu extends LitElement implements Layer { public playerActions: PlayerActions | null; private filteredBuildTable: BuildItemDisplay[][] = buildTable; public transformHandler: TransformHandler; + + // Add reference to fogOfWarLayer + public fogOfWarLayer: any = null; init() { this.eventBus.on(ShowBuildMenuEvent, (e) => { @@ -151,6 +155,20 @@ export class BuildMenu extends LitElement implements Layer { return; } const tile = this.game.ref(clickedCell.x, clickedCell.y); + + // Check if we are in Fog of War mode and if the position is completely fogged (fog = 1) + if (this.game.config().gameConfig().gameMode === GameMode.FogOfWar && this.fogOfWarLayer) { + const x = this.game.x(tile); + const y = this.game.y(tile); + const idx = y * this.game.width() + x; + const fogValue = this.fogOfWarLayer.getFogValueAt(idx); + + // If fog is completely fogged (value 1), don't show the menu + if (fogValue >= 1.0) { + return; + } + } + this.showMenu(tile); }); this.eventBus.on(CloseViewEvent, () => this.hideMenu()); diff --git a/src/client/graphics/layers/FogOfWarLayer.ts b/src/client/graphics/layers/FogOfWarLayer.ts new file mode 100644 index 000000000..aa3aa36cb --- /dev/null +++ b/src/client/graphics/layers/FogOfWarLayer.ts @@ -0,0 +1,516 @@ +import { GameView } from "../../../core/game/GameView"; +import { GameMode } from "../../../core/game/Game"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "./Layer"; + +export class FogOfWarLayer implements Layer { + private fogCanvas: HTMLCanvasElement; + private fogContext: CanvasRenderingContext2D; + private fogImageData: ImageData; + + // Fog of War state (0.0 = fully visible, 0.8-1.0 = fog) + private fogMap: Float32Array; + + // Territory map + // 0 = neutral + // 1 = controlled by player + private territoryMap: Uint8Array; + + // Chunk system for optimization + private chunks: FogChunk[]; + private chunkSize: number = 8; + private chunksX: number; + private chunksY: number; + + // Territory tracking + private territory: Set; // stores indices: y * mapWidth + x + + // Update timing + private lastFogUpdate: number = 0; + private updateInterval: number = 100; // milliseconds - increased frequency for smoother transitions + + constructor( + private game: GameView, + private transformHandler: TransformHandler, + ) { + this.territory = new Set(); + + // Initialize fog map with Float32Array for smooth fading + this.fogMap = new Float32Array(this.game.width() * this.game.height()).fill(1.0); // 1.0 = never seen + + // Initialize territory map + this.territoryMap = new Uint8Array(this.game.width() * this.game.height()).fill(0); + + // Initialize chunks + this.chunksX = Math.ceil(this.game.width() / this.chunkSize); + this.chunksY = Math.ceil(this.game.height() / this.chunkSize); + this.chunks = []; + + for (let y = 0; y < this.chunksY; y++) { + for (let x = 0; x < this.chunksX; x++) { + this.chunks.push({ + x, + y, + isDirty: true, + startX: x * this.chunkSize, + startY: y * this.chunkSize, + endX: Math.min((x + 1) * this.chunkSize, this.game.width()), + endY: Math.min((y + 1) * this.chunkSize, this.game.height()) + }); + } + } + } + + shouldTransform(): boolean { + return true; + } + + init() { + // Create fog canvas + this.fogCanvas = document.createElement("canvas"); + this.fogCanvas.width = this.game.width(); + this.fogCanvas.height = this.game.height(); + + const context = this.fogCanvas.getContext("2d", { alpha: true }); + if (context === null) throw new Error("2d context not supported"); + this.fogContext = context; + + // Create image data for efficient rendering + this.fogImageData = this.fogContext.createImageData( + this.game.width(), + this.game.height() + ); + + // Mark all chunks as dirty for initial render + this.markAllChunksDirty(); + } + + redraw() { + // Mark all chunks as dirty when redrawing + this.markAllChunksDirty(); + this.renderFog(); + } + + tick() { + // Only update fog in Fog of War mode + if (this.game.config().gameConfig().gameMode !== GameMode.FogOfWar) { + return; + } + + const now = Date.now(); + if (now - this.lastFogUpdate < this.updateInterval) { + return; + } + this.lastFogUpdate = now; + + let hasChanges = false; + + // Gradually fade out vision that is no longer updated + // Only fade values that are less than 0.8 (visible areas) + // This creates a smooth transition from visible (0.0) to remembered (0.8) to unknown (1.0) + for (let i = 0; i < this.fogMap.length; i++) { + if (this.fogMap[i] < 0.8) { // Only fade values that are less than 0.8 (visible areas) + // Slowly return to 0.8 (remembered territory), but never to 1.0 (never seen) + const newValue = Math.min(this.fogMap[i] + 0.005, 0.8); // Increased fade rate for smoother transition + if (Math.abs(newValue - this.fogMap[i]) > 0.0001) { + this.fogMap[i] = newValue; + hasChanges = true; + + // Mark the chunk as dirty + const x = i % this.game.width(); + const y = Math.floor(i / this.game.width()); + const chunkX = Math.floor(x / this.chunkSize); + const chunkY = Math.floor(y / this.chunkSize); + const chunkIndex = chunkY * this.chunksX + chunkX; + if (chunkIndex < this.chunks.length) { + this.chunks[chunkIndex].isDirty = true; + } + } + } + } + + // Update vision for player's units with dynamic fading + const myPlayer = this.game.myPlayer(); + if (myPlayer) { + const units = myPlayer.units(); + for (const unit of units) { + // Get vision range based on unit type + const visionRange = this.getUnitVisionRange(unit); + this.updateVisionWithFade(unit.tile(), visionRange); + } + + // Update vision for player's territory borders with 20 tile radius + this.updateTerritoryBorderVision(myPlayer); + + // Update vision from allied players in Fog of War mode + if (this.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + this.updateAlliedVision(myPlayer); + } + } + + // Update vision for claimed territory + for (const idx of this.territory) { + if (this.fogMap[idx] > 0.0) { + this.fogMap[idx] = 0.0; // totally visible + hasChanges = true; + + // Mark the chunk as dirty + const x = idx % this.game.width(); + const y = Math.floor(idx / this.game.width()); + const chunkX = Math.floor(x / this.chunkSize); + const chunkY = Math.floor(y / this.chunkSize); + const chunkIndex = chunkY * this.chunksX + chunkX; + if (chunkIndex < this.chunks.length) { + this.chunks[chunkIndex].isDirty = true; + } + } + } + + // Only mark all chunks as dirty if there are significant changes + // This optimization prevents unnecessary rendering when no visibility changes occur + if (hasChanges) { + // Additional check: if many chunks are dirty, it's more efficient to render all at once + const dirtyChunkCount = this.chunks.filter(chunk => chunk.isDirty).length; + if (dirtyChunkCount > this.chunks.length * 0.5) { + this.markAllChunksDirty(); + } + } + } + + renderLayer(context: CanvasRenderingContext2D) { + // Only render if the game mode is Fog of War + if (this.game.config().gameConfig().gameMode !== GameMode.FogOfWar) { + return; + } + + // Only render if there are dirty chunks + if (this.hasDirtyChunks()) { + this.renderFog(); + } + + // Draw the fog canvas onto the main context + context.drawImage( + this.fogCanvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height() + ); + } + + private updateVisionWithFade(centerTile: number, radius: number) { + const centerX = this.game.x(centerTile); + const centerY = this.game.y(centerTile); + + // Create a circular vision range with dynamic fading + const radiusSq = radius * radius; + + for (let i = -radius; i <= radius; i++) { + for (let j = -radius; j <= radius; j++) { + // Check if the tile is within the circular radius + if (i * i + j * j <= radiusSq) { + const dx = centerX + i; + const dy = centerY + j; + + if (dx >= 0 && dy >= 0 && dx < this.game.width() && dy < this.game.height()) { + const dist = Math.sqrt(i * i + j * j); + if (dist < radius) { + // Calculate fade based on distance: closer = more visible + // Using a smoother curve for more natural transition + const normalizedDist = Math.max(0.0, Math.min(1.0, dist / radius)); + // Apply easing function for smoother transition: 0.8 * (1 - (1 - dist)^2) + const alpha = 0.8 * (1 - Math.pow(1 - normalizedDist, 2)); + const idx = dy * this.game.width() + dx; + + // Only update if the new value is lower (more visible) + if (alpha < this.fogMap[idx]) { + this.fogMap[idx] = alpha; + + // Mark the chunk as dirty + const chunkX = Math.floor(dx / this.chunkSize); + const chunkY = Math.floor(dy / this.chunkSize); + const chunkIndex = chunkY * this.chunksX + chunkX; + if (chunkIndex < this.chunks.length) { + this.chunks[chunkIndex].isDirty = true; + } + } + } + } + } + } + } + } + + // New method to update vision from allied players + private updateAlliedVision(myPlayer: any) { + // Get all allied players + const alliedPlayers = this.game.playerViews().filter(player => + player.id() !== myPlayer.id() && myPlayer.isAlliedWith(player) + ); + + // Update vision from each allied player's units + for (const alliedPlayer of alliedPlayers) { + const units = alliedPlayer.units(); + for (const unit of units) { + // Get vision range based on unit type + const visionRange = this.getUnitVisionRange(unit); + this.updateVisionWithFade(unit.tile(), visionRange); + } + + // Update vision from allied player's territory borders + if (typeof alliedPlayer.borderTiles === 'function') { + const borderTilesResult = alliedPlayer.borderTiles(); + + // Handle both synchronous and asynchronous borderTiles + if (borderTilesResult instanceof Promise) { + // For asynchronous case (PlayerView) + borderTilesResult.then((result: any) => { + const borderTiles = result.borderTiles || result; + this.applyAlliedBorderVision(borderTiles); + }).catch((error: any) => { + console.warn("Failed to get allied player border tiles:", error); + }); + } else { + // For synchronous case + const borderTiles = borderTilesResult; + this.applyAlliedBorderVision(borderTiles); + } + } + } + } + + // Apply vision to allied border tiles and surrounding area + private applyAlliedBorderVision(borderTiles: any) { + // Set border tiles to fully visible (0.0) + for (const tile of borderTiles) { + const x = this.game.x(tile); + const y = this.game.y(tile); + + if (x >= 0 && y >= 0 && x < this.game.width() && y < this.game.height()) { + const idx = y * this.game.width() + x; + if (this.fogMap[idx] > 0.0) { + this.fogMap[idx] = 0.0; // totally visible + + // Mark the chunk as dirty + const chunkX = Math.floor(x / this.chunkSize); + const chunkY = Math.floor(y / this.chunkSize); + const chunkIndex = chunkY * this.chunksX + chunkX; + if (chunkIndex < this.chunks.length) { + this.chunks[chunkIndex].isDirty = true; + } + } + } + + // Also apply vision radius around each border tile + this.updateVisionWithFade(tile, 20); + } + } + + private renderFog() { + // Update only dirty chunks + for (const chunk of this.chunks) { + if (!chunk.isDirty) continue; + + // Render this chunk + for (let y = chunk.startY; y < chunk.endY; y++) { + for (let x = chunk.startX; x < chunk.endX; x++) { + const idx = y * this.game.width() + x; + + // Calculate pixel index in image data + const pixelIndex = (y * this.game.width() + x) * 4; + + // First draw the territory (layer below fog) + if (this.territoryMap[idx] === 1) { + // Draw territory with green tint + this.fogImageData.data[pixelIndex + 0] = 0; // R + this.fogImageData.data[pixelIndex + 1] = 128; // G + this.fogImageData.data[pixelIndex + 2] = 0; // B + this.fogImageData.data[pixelIndex + 3] = 51; // A (20% opacity) + } else { + // Clear territory pixel + this.fogImageData.data[pixelIndex + 0] = 0; // R + this.fogImageData.data[pixelIndex + 1] = 0; // G + this.fogImageData.data[pixelIndex + 2] = 0; // B + this.fogImageData.data[pixelIndex + 3] = 0; // A + } + + // Then draw the fog layer on top with dynamic alpha + const fogValue = this.fogMap[idx]; + + // Apply fog with dynamic alpha based on visibility + // 0.0 = totally visible (transparent) + // 0.8-1.0 = fog (black with varying opacity) + const alpha = Math.min(255, Math.max(0, Math.floor(fogValue * 255))); + + // Add subtle texture variation for visual interest + const base = 20 + (Math.random() * 10); // Simple noise texture + + this.fogImageData.data[pixelIndex + 0] = base; // R + this.fogImageData.data[pixelIndex + 1] = base; // G + this.fogImageData.data[pixelIndex + 2] = base; // B + this.fogImageData.data[pixelIndex + 3] = alpha; // A (dynamic opacity) + } + } + + // Mark chunk as clean + chunk.isDirty = false; + } + + // Put the updated image data to the canvas + this.fogContext.putImageData(this.fogImageData, 0, 0); + } + + private markAllChunksDirty() { + for (const chunk of this.chunks) { + chunk.isDirty = true; + } + } + + private hasDirtyChunks(): boolean { + return this.chunks.some(chunk => chunk.isDirty); + } + + // Public method to claim territory (make it permanently visible) + public claimTerritory(x: number, y: number) { + if (x >= 0 && y >= 0 && x < this.game.width() && y < this.game.height()) { + const idx = y * this.game.width() + x; + this.territory.add(idx); + this.fogMap[idx] = 0.0; // totally visible + + // Mark the chunk as dirty + const chunkX = Math.floor(x / this.chunkSize); + const chunkY = Math.floor(y / this.chunkSize); + const chunkIndex = chunkY * this.chunksX + chunkX; + if (chunkIndex < this.chunks.length) { + this.chunks[chunkIndex].isDirty = true; + } + } + } + + // Get fog value at specific index + public getFogValueAt(index: number): number { + if (index >= 0 && index < this.fogMap.length) { + return this.fogMap[index]; + } + return 1.0; // Default to fully fogged if index is out of bounds + } + + // Get vision range based on unit type + private getUnitVisionRange(unit: any): number { + // Get unit type + const unitType = unit.type(); + + // Get unit level (default to 1 if not available) + const level = typeof unit.level === 'function' ? unit.level() : 1; + + // Calculate vision range boost: 20% per level + const visionBoost = 1 + (level - 1) * 0.2; + + // Define base vision ranges for different unit types + let baseVisionRange = 15; // Default vision range for other units + + switch(unitType) { + case "City": + baseVisionRange = 30; // Cities have long vision range + break; + case "Port": + baseVisionRange = 80; // Ports have good vision range + break; + case "Defense Post": + baseVisionRange = 70; // Defense posts have moderate vision range + break; + case "Warship": + baseVisionRange = 140; // Warships have good vision range + break; + case "Missile Silo": + baseVisionRange = 200; // Missile silos have moderate vision range + break; + case "SAM Launcher": + baseVisionRange = 400; // SAM launchers have moderate vision range + break; + case "Factory": + baseVisionRange = 35; // Factories have moderate vision range + break; + case "Atom Bomb": + baseVisionRange = 30; // Atom bombs have limited vision range + break; + case "Hydrogen Bomb": + baseVisionRange = 80; // Hydrogen bombs have moderate vision range + break; + case "MIRV": + baseVisionRange = 100; // MIRV bombs have good vision range + break; + } + + // Apply vision boost for upgradable units + // Upgradable units: City, Port, Missile Silo, SAM Launcher, Factory + const upgradableUnits = ["City", "Port", "Missile Silo", "SAM Launcher", "Factory"]; + if (upgradableUnits.includes(unitType)) { + return Math.round(baseVisionRange * visionBoost); + } + + // Return base vision range for non-upgradable units + return baseVisionRange; + } + + // Update vision for territory borders + private updateTerritoryBorderVision(player: any) { + // Get player's border tiles + if (typeof player.borderTiles === 'function') { + const borderTilesResult = player.borderTiles(); + + // Handle both synchronous and asynchronous borderTiles + if (borderTilesResult instanceof Promise) { + // For asynchronous case (PlayerView) + borderTilesResult.then((result: any) => { + const borderTiles = result.borderTiles || result; + this.applyBorderVision(borderTiles); + }).catch((error: any) => { + console.warn("Failed to get border tiles:", error); + }); + } else { + // For synchronous case (PlayerImpl) + const borderTiles = borderTilesResult; + this.applyBorderVision(borderTiles); + } + } + } + + // Apply vision to border tiles and surrounding area + private applyBorderVision(borderTiles: any) { + // Set border tiles to fully visible (0.0) + for (const tile of borderTiles) { + const x = this.game.x(tile); + const y = this.game.y(tile); + + if (x >= 0 && y >= 0 && x < this.game.width() && y < this.game.height()) { + const idx = y * this.game.width() + x; + if (this.fogMap[idx] > 0.0) { + this.fogMap[idx] = 0.0; // totally visible + + // Mark the chunk as dirty + const chunkX = Math.floor(x / this.chunkSize); + const chunkY = Math.floor(y / this.chunkSize); + const chunkIndex = chunkY * this.chunksX + chunkX; + if (chunkIndex < this.chunks.length) { + this.chunks[chunkIndex].isDirty = true; + } + } + } + + // Also apply vision radius around each border tile + this.updateVisionWithFade(tile, 20); + } + } +} + +// Chunk interface for optimization +interface FogChunk { + x: number; + y: number; + isDirty: boolean; + startX: number; + startY: number; + endX: number; + endY: number; +} \ No newline at end of file diff --git a/src/client/graphics/layers/GameLeftSidebar.ts b/src/client/graphics/layers/GameLeftSidebar.ts index dd9b00e98..f49421e45 100644 --- a/src/client/graphics/layers/GameLeftSidebar.ts +++ b/src/client/graphics/layers/GameLeftSidebar.ts @@ -7,6 +7,7 @@ import teamRegularIcon from "../../../../resources/images/TeamIconRegularWhite.s import teamSolidIcon from "../../../../resources/images/TeamIconSolidWhite.svg"; import { GameMode } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; +import { EventBus } from "../../../core/EventBus"; import { translateText } from "../../Utils"; import { Layer } from "./Layer"; @@ -22,6 +23,7 @@ export class GameLeftSidebar extends LitElement implements Layer { private playerColor: Colord = new Colord("#FFFFFF"); public game: GameView; + public eventBus: EventBus; // Adicionando a propriedade eventBus private _shownOnInit = false; createRenderRoot() { @@ -66,11 +68,34 @@ export class GameLeftSidebar extends LitElement implements Layer { } private toggleLeaderboard(): void { + // In Fog of War mode, check if the player is eliminated before allowing to switch to global mode + if (this.game?.config().gameConfig().gameMode === GameMode.FogOfWar) { + const myPlayer = this.game.myPlayer(); + const isPlayerEliminated = myPlayer !== null && !myPlayer.isAlive(); + + // If the player is alive, only allow local mode + if (myPlayer && myPlayer.isAlive()) { + // Ensure the leaderboard is in local mode + const leaderboard = this.querySelector('leader-board') as any; + if (leaderboard && leaderboard._leaderboardMode !== "local") { + leaderboard._leaderboardMode = "local"; + } + } + } + this.isLeaderboardShow = !this.isLeaderboardShow; + // Emitting event when the leaderboard is toggled + if (this.eventBus) { + this.eventBus.emit({ type: "leaderboardToggled", show: this.isLeaderboardShow }); + } } private toggleTeamLeaderboard(): void { this.isTeamLeaderboardShow = !this.isTeamLeaderboardShow; + // Emitting event when the team leaderboard is toggled + if (this.eventBus) { + this.eventBus.emit({ type: "teamLeaderboardToggled", show: this.isTeamLeaderboardShow }); + } } private get isTeamGame(): boolean { @@ -148,4 +173,4 @@ export class GameLeftSidebar extends LitElement implements Layer { `; } -} +} \ No newline at end of file diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 3ac7ce450..7fe99ea12 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -7,6 +7,7 @@ import replayRegularIcon from "../../../../resources/images/ReplayRegularIconWhi import replaySolidIcon from "../../../../resources/images/ReplaySolidIconWhite.svg"; import settingsIcon from "../../../../resources/images/SettingIconWhite.svg"; import { EventBus } from "../../../core/EventBus"; +import { GameMode } from "../../../core/game/Game"; import { GameType } from "../../../core/game/Game"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; @@ -15,11 +16,13 @@ import { translateText } from "../../Utils"; import { Layer } from "./Layer"; import { ShowReplayPanelEvent } from "./ReplayPanel"; import { ShowSettingsModalEvent } from "./SettingsModal"; +import { FogOfWarLayer } from "./FogOfWarLayer"; @customElement("game-right-sidebar") export class GameRightSidebar extends LitElement implements Layer { public game: GameView; public eventBus: EventBus; + public fogOfWarLayer: FogOfWarLayer | null = null; // Reference to FogOfWarLayer @state() private _isSinglePlayer: boolean = false; @@ -104,9 +107,88 @@ export class GameRightSidebar extends LitElement implements Layer { ); } + // Check if there are visible players or bots in Fog of War mode + private hasVisiblePlayersOrBots(): boolean { + // If not in Fog of War mode, show the sidebar + if (!this.game || this.game.config().gameConfig().gameMode !== GameMode.FogOfWar || !this.fogOfWarLayer) { + return true; + } + + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return false; + } + + // Verificar todos os jogadores + const playerViews = this.game.playerViews(); + for (const player of playerViews) { + // Ignore the own player + if (player.id() === myPlayer.id()) { + continue; + } + + // Check if the player is alive + if (!player.isAlive()) { + continue; + } + + // Check if the player has name location + const nameLocation = player.nameLocation(); + if (!nameLocation) { + continue; + } + + // Check if the player is visible (not covered by fog) + const x = nameLocation.x; + const y = nameLocation.y; + + if (x >= 0 && y >= 0 && x < this.game.width() && y < this.game.height()) { + const idx = y * this.game.width() + x; + const fogValue = this.fogOfWarLayer.getFogValueAt(idx); + + // If fog is between 0.0 and 0.8 (visible or remembered area), the player is visible + if (fogValue < 0.8) { + return true; + } + } + } + + // Verificar unidades (bots) de outros jogadores + const units = this.game.units(); + for (const unit of units) { + // Check units that don't belong to the current player + if (unit.owner().id() !== myPlayer.id()) { + // Get the unit position + const tile = unit.tile(); + const x = this.game.x(tile); + const y = this.game.y(tile); + + if (x >= 0 && y >= 0 && x < this.game.width() && y < this.game.height()) { + const idx = y * this.game.width() + x; + const fogValue = this.fogOfWarLayer.getFogValueAt(idx); + + // If fog is between 0.0 and 0.8 (visible or remembered area), the unit is visible + if (fogValue < 0.8) { + return true; + } + } + } + } + + // If we didn't find any visible player or bot, don't show the sidebar + return false; + } + render() { if (this.game === undefined) return html``; + // In Fog of War mode, only show the sidebar if there are visible players or bots + const shouldShowSidebar = this.hasVisiblePlayersOrBots(); + + if (!shouldShowSidebar) { + return html``; + } + return html`