From baf866d099c190ce501ef8ee309460ccb59b61c8 Mon Sep 17 00:00:00 2001 From: variablevince <24507472+VariableVince@users.noreply.github.com> Date: Fri, 2 Jan 2026 22:32:23 +0100 Subject: [PATCH] Fow test 3 (based on files shared on 2 Jan 2026. Misses files like Game.ts that where present in the previously shared files) --- resources/lang/en.json | 27 +- src/client/ClientGameRunner.ts | 50 +- src/client/SinglePlayerModal.ts | 19 +- src/client/graphics/GameRenderer.ts | 80 ++- src/client/graphics/layers/FogOfWarLayer.ts | 619 ++++++++++++++++++ src/client/graphics/layers/Leaderboard.ts | 334 +++++++++- src/client/graphics/layers/MainRadialMenu.ts | 24 +- src/client/graphics/layers/NameLayer.ts | 132 +++- .../graphics/layers/PlayerInfoOverlay.ts | 51 +- .../graphics/layers/RadialMenuElements.ts | 249 ++++++- src/client/graphics/layers/UILayer.ts | 40 +- src/client/graphics/layers/UnitLayer.ts | 85 ++- src/core/game/FogOfWar.ts | 193 ++++++ src/core/game/GameImpl.ts | 45 +- 14 files changed, 1850 insertions(+), 98 deletions(-) create mode 100644 src/client/graphics/layers/FogOfWarLayer.ts create mode 100644 src/core/game/FogOfWar.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 7f9a2734a..d77b60b7d 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -84,8 +84,6 @@ "radial_attack": "Open the Attack menu.", "radial_info": "Open the Info menu.", "radial_boat": "Send a Boat (transport ship) to attack at the selected location. Only available if you have access to water.", - "radial_donate_troops": "Donate troops equivalent to your attack ratio slider percentage to the ally you opened the radial menu on.", - "radial_donate_gold": "Opens the gold donation slider menu so you can quickly send allies gold.", "radial_close": "Close the menu.", "info_title": "Info menu", "info_enemy_desc": "Contains information such as the selected player's name, gold, troops, stopped trading with you, nukes sent to you, and if the player is a traitor. Stopped trading means you won't receive gold from them and they won't sent you gold via trade ships. Manually (if the player clicked \"Stop trading\", which lasts until you both click \"Start trading\") or automatically (if you betrayed your alliance, which lasts until you become allies again or after 5 minutes). Traitor displays Yes for 30 seconds when the player betrayed and attacked a player who was in an alliance with them. The icons below represent the following interactions:", @@ -276,11 +274,11 @@ "public_lobby": { "join": "Join next Game", "waiting": "players waiting", - "teams_Duos": "{team_count} of 2 (Duos)", - "teams_Trios": "{team_count} of 3 (Trios)", - "teams_Quads": "{team_count} of 4 (Quads)", "waiting_for_players": "Waiting for players", "starting_game": "Starting game…", + "teams_Duos": "of 2 (Duos)", + "teams_Trios": "of 3 (Trios)", + "teams_Quads": "of 4 (Quads)", "teams_hvn": "Humans vs Nations", "teams_hvn_detailed": "{num} Humans vs {num} Nations", "teams": "{num} teams", @@ -348,7 +346,7 @@ "code_license": "Code licensed under AGPL-3.0 (no warranty)" }, "difficulty": { - "difficulty": "Nation difficulty", + "difficulty": "Difficulty", "easy": "Easy", "medium": "Medium", "hard": "Hard", @@ -356,7 +354,8 @@ }, "game_mode": { "ffa": "Free for All", - "teams": "Teams" + "teams": "Teams", + "fog_of_war": "Fog of War" }, "select_lang": { "title": "Select Language" @@ -603,7 +602,13 @@ "warships": "Warships", "cities": "Cities", "show_control": "Show Control", - "show_units": "Show Units" + "show_units": "Show Units", + "local_mode": "Local Mode", + "global_mode": "Global Mode", + "eliminated": "Eliminated", + "view": "View", + "switch_to_global": "Switch to Global", + "switch_to_local": "Switch to Local" }, "player_info_overlay": { "type": "Type", @@ -702,10 +707,7 @@ "send_alliance": "Send Alliance", "send_troops": "Send Troops", "send_gold": "Send Gold", - "emotes": "Emojis", - "arc_up": "Upward arc", - "arc_down": "Downward arc", - "flip_rocket_trajectory": "Flip rocket trajectory" + "emotes": "Emojis" }, "send_troops_modal": { "title_with_name": "Send Troops to {name}", @@ -862,3 +864,4 @@ "mode_team": "Team" } } + diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 27c2f01cb..5df7ac228 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 { @@ -501,6 +501,22 @@ export class ClientGameRunner { if (myPlayer === null) return; this.myPlayer = myPlayer; } + // Check if we are in Fog of War mode and if the position is completely fogged (fog = 1) + if (this.gameView.config().gameConfig().gameMode === GameMode.FogOfWar) { + const fogOfWarLayer = this.renderer.getFogOfWarLayer(); + if (fogOfWarLayer && typeof fogOfWarLayer.getFogValueAt === 'function') { + const tileX = this.gameView.x(tile); + const tileY = this.gameView.y(tile); + const idx = tileY * this.gameView.width() + tileX; + const fogValue = fogOfWarLayer.getFogValueAt(idx); + + // If it's exactly fog = 1, don't allow any type of attack (ground or naval) + if (fogValue >= 1.0) { + return; + } + } + } + this.myPlayer.actions(tile).then((actions) => { if (this.myPlayer === null) return; if (actions.canAttack) { @@ -598,6 +614,22 @@ export class ClientGameRunner { this.myPlayer = myPlayer; } + // Check if we are in Fog of War mode and if the position is completely fogged (fog = 1) + if (this.gameView.config().gameConfig().gameMode === GameMode.FogOfWar) { + const fogOfWarLayer = this.renderer.getFogOfWarLayer(); + if (fogOfWarLayer && typeof fogOfWarLayer.getFogValueAt === 'function') { + const tileX = this.gameView.x(tile); + const tileY = this.gameView.y(tile); + const idx = tileY * this.gameView.width() + tileX; + const fogValue = fogOfWarLayer.getFogValueAt(idx); + + // If it's exactly fog = 1, don't allow the boat attack + if (fogValue >= 1.0) { + return; + } + } + } + this.myPlayer.actions(tile).then((actions) => { if (this.canBoatAttack(actions) !== false) { this.sendBoatAttackIntent(tile); @@ -617,6 +649,22 @@ export class ClientGameRunner { this.myPlayer = myPlayer; } + // Check if we are in Fog of War mode and if the position is completely fogged (fog = 1) + if (this.gameView.config().gameConfig().gameMode === GameMode.FogOfWar) { + const fogOfWarLayer = this.renderer.getFogOfWarLayer(); + if (fogOfWarLayer && typeof fogOfWarLayer.getFogValueAt === 'function') { + const tileX = this.gameView.x(tile); + const tileY = this.gameView.y(tile); + const idx = tileY * this.gameView.width() + tileX; + const fogValue = fogOfWarLayer.getFogValueAt(idx); + + // If it's exactly fog = 1, don't allow the attack (naval or ground invasion) + if (fogValue >= 1.0) { + return; + } + } + } + this.myPlayer.actions(tile).then((actions) => { if (this.myPlayer === null) return; if (actions.canAttack) { diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 76917c21d..e2e9fcb2a 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -190,10 +190,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` @@ -218,7 +228,7 @@ export class SinglePlayerModal extends LitElement {
this.handleTeamCountSelection(o)} >
@@ -498,6 +508,11 @@ export class SinglePlayerModal extends LitElement { private handleGameModeSelection(value: GameMode) { this.gameMode = value; + + // When Fog of War mode is selected, always enable random spawn + if (value === GameMode.FogOfWar) { + this.randomSpawn = true; + } } private handleTeamCountSelection(value: TeamCountConfig) { diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index ffb642ab7..a7ddda501 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -19,7 +19,7 @@ import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { GameRightSidebar } from "./layers/GameRightSidebar"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { Layer } from "./layers/Layer"; -import { Leaderboard } from "./layers/Leaderboard"; +import { Leaderboard, ViewPlayerVisionEvent } from "./layers/Leaderboard"; import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; @@ -41,6 +41,7 @@ import { UILayer } from "./layers/UILayer"; import { UnitDisplay } from "./layers/UnitDisplay"; import { UnitLayer } from "./layers/UnitLayer"; import { WinModal } from "./layers/WinModal"; +import { FogOfWarLayer } from "./layers/FogOfWarLayer"; export function createRenderer( canvas: HTMLCanvasElement, @@ -209,7 +210,10 @@ export function createRenderer( } headsUpMessage.game = game; + // Create FogOfWarLayer first so we can pass it to other layers + // Camada responsável por gerenciar o efeito visual do Fog of War const structureLayer = new StructureLayer(game, eventBus, transformHandler); + const fogOfWarLayer = new FogOfWarLayer(game, transformHandler); const samRadiusLayer = new SAMRadiusLayer(game, eventBus, uiState); const performanceOverlay = document.querySelector( @@ -236,6 +240,22 @@ export function createRenderer( // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. + + // Create MainRadialMenu first to set references + const mainRadialMenu = new MainRadialMenu( + eventBus, + game, + transformHandler, + emojiTable as EmojiTable, + buildMenu, + uiState, + playerPanel, + ); + + // Set references to FogOfWarLayer + const nameLayer = new NameLayer(game, transformHandler, eventBus, fogOfWarLayer); + mainRadialMenu.setFogOfWarLayer(fogOfWarLayer); + // Not grouping the layers may cause excessive calls to context.save() and context.restore(). const layers: Layer[] = [ new TerrainLayer(game, transformHandler), @@ -243,24 +263,17 @@ export function createRenderer( new RailroadLayer(game, eventBus, transformHandler), structureLayer, samRadiusLayer, - new UnitLayer(game, eventBus, transformHandler), + new UnitLayer(game, eventBus, transformHandler, fogOfWarLayer), new FxLayer(game), - new UILayer(game, eventBus, transformHandler), + new UILayer(game, eventBus, transformHandler, fogOfWarLayer), new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), new StructureIconsLayer(game, eventBus, uiState, transformHandler), - new NameLayer(game, transformHandler, eventBus), + nameLayer, + fogOfWarLayer, eventsDisplay, chatDisplay, buildMenu, - new MainRadialMenu( - eventBus, - game, - transformHandler, - emojiTable as EmojiTable, - buildMenu, - uiState, - playerPanel, - ), + mainRadialMenu, spawnTimer, leaderboard, gameLeftSidebar, @@ -280,6 +293,27 @@ export function createRenderer( performanceOverlay, ]; + // Set reference to FogOfWarLayer for Leaderboard + const fogOfWarLayerRef = layers.find(layer => layer.constructor.name === 'FogOfWarLayer') as any; + if (fogOfWarLayerRef && leaderboard) { + leaderboard.fogOfWarLayer = fogOfWarLayerRef; + } + + // Set reference to FogOfWarLayer for PlayerInfoOverlay + if (fogOfWarLayerRef && playerInfo) { + playerInfo.fogOfWarLayer = fogOfWarLayerRef; + } + + // Set reference to FogOfWarLayer for MainRadialMenu + if (fogOfWarLayerRef && mainRadialMenu) { + mainRadialMenu.setFogOfWarLayer(fogOfWarLayerRef); + } + + // Set reference to FogOfWarLayer for Leaderboard + if (fogOfWarLayerRef && leaderboard) { + leaderboard.fogOfWarLayer = fogOfWarLayerRef; + } + return new GameRenderer( game, eventBus, @@ -310,6 +344,22 @@ export class GameRenderer { initialize() { this.eventBus.on(RedrawGraphicsEvent, () => this.redraw()); + + // Handle ViewPlayerVisionEvent to switch between player views in Fog of War mode + this.eventBus.on(ViewPlayerVisionEvent, (event: ViewPlayerVisionEvent) => { + // Find the FogOfWarLayer and set the observed player + const fogOfWarLayer = this.layers.find(l => l instanceof FogOfWarLayer) as FogOfWarLayer; + if (fogOfWarLayer) { + fogOfWarLayer.setObservedPlayer(event.player); + } + + // Also update the Leaderboard if available + const leaderboard = this.layers.find(l => l instanceof Leaderboard) as Leaderboard; + if (leaderboard) { + leaderboard.setViewingPlayerVision(event.player); + } + }); + this.layers.forEach((l) => l.init?.()); // only append the canvas if it's not already in the document to avoid reparenting side-effects @@ -407,6 +457,10 @@ export class GameRenderer { this.layers.forEach((l) => l.tick?.()); } + getFogOfWarLayer() { + return this.layers.find(layer => layer.constructor.name === 'FogOfWarLayer') as any; + } + resize(width: number, height: number): void { this.canvas.width = Math.ceil(width / window.devicePixelRatio); this.canvas.height = Math.ceil(height / window.devicePixelRatio); diff --git a/src/client/graphics/layers/FogOfWarLayer.ts b/src/client/graphics/layers/FogOfWarLayer.ts new file mode 100644 index 000000000..ba0b27179 --- /dev/null +++ b/src/client/graphics/layers/FogOfWarLayer.ts @@ -0,0 +1,619 @@ +import { GameView } from "../../../core/game/GameView"; +import { GameMode } from "../../../core/game/Game"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "./Layer"; + +interface FogChunk { + x: number; y: number; isDirty: boolean; + startX: number; startY: number; endX: number; endY: number; +} + +/** + * Camada gráfica responsável por renderizar o efeito visual do Fog of War (nevoeiro de guerra). + * Controla a visibilidade dos elementos gráficos baseado na lógica do servidor. + */ +export class FogOfWarLayer implements Layer { + private fullW: number; + private fullH: number; + private scale: number = 1; + private lowW: number; + private lowH: number; + + private fogMap: Uint8ClampedArray; + private visionBuffer: Uint8ClampedArray; + private territoryMap: Uint8Array; + + private fogCanvas: HTMLCanvasElement; + private fogCtx: CanvasRenderingContext2D; + private fogImageData: ImageData; + + private chunks: FogChunk[] = []; + private readonly CHUNK_SIZE = 16; + + private lastVisionUpdate = 0; + private readonly VISION_INTERVAL = 100; + + private _observedPlayer: any = null; + + // Cache to track units and borders that have already granted vision + private visionCache: Map = new Map(); + private borderVisionCache: Map> = new Map(); + + private MOBILE_FOG_THRESHOLD = 204; // Represents fog 0.8 (204/255 ≈ 0.8) + + // Track previous alliances to detect changes + private previousAllies: Set = new Set(); + + constructor( + private game: GameView, + private transformHandler: TransformHandler, + ) { + this.fullW = game.width(); + this.fullH = game.height(); + + const area = this.fullW * this.fullH; + if (area > 3_000_000) this.scale = 4; + else if (area > 1_200_000) this.scale = 2; + else if (area > 500_000) this.scale = 2; + else this.scale = 1; + + this.lowW = Math.ceil(this.fullW / this.scale); + this.lowH = Math.ceil(this.fullH / this.scale); + const lowSize = this.lowW * this.lowH; + + this.fogMap = new Uint8ClampedArray(lowSize).fill(255); + this.visionBuffer = new Uint8ClampedArray(lowSize).fill(255); + this.territoryMap = new Uint8Array(lowSize); + + this.setupChunks(); + } + + private setupChunks() { + const chunksX = Math.ceil(this.lowW / this.CHUNK_SIZE); + const chunksY = Math.ceil(this.lowH / this.CHUNK_SIZE); + for (let cy = 0; cy < chunksY; cy++) { + for (let cx = 0; cx < chunksX; cx++) { + this.chunks.push({ + x: cx, y: cy, isDirty: true, + startX: cx * this.CHUNK_SIZE, + startY: cy * this.CHUNK_SIZE, + endX: Math.min((cx + 1) * this.CHUNK_SIZE, this.lowW), + endY: Math.min((cy + 1) * this.CHUNK_SIZE, this.lowH), + }); + } + } + } + + shouldTransform(): boolean { return true; } + + init() { + this.fogCanvas = document.createElement("canvas"); + this.fogCanvas.width = this.lowW; + this.fogCanvas.height = this.lowH; + const ctx = this.fogCanvas.getContext("2d", { alpha: true }); + if (!ctx) throw new Error("2D context not supported"); + this.fogCtx = ctx; + this.fogImageData = this.fogCtx.createImageData(this.lowW, this.lowH); + this.markAllDirty(); + + // Initialize the vision buffer + this.visionBuffer.fill(255); + } + + redraw() { this.markAllDirty(); this.renderFog(); } + + async tick() { + if (this.game.config().gameConfig().gameMode !== GameMode.FogOfWar) return; + const now = performance.now(); + + if (now - this.lastVisionUpdate >= this.VISION_INTERVAL) { + await this.updateVision(); + this.lastVisionUpdate = now; + } + + this.handleAllianceChanges(); + this.applyPermanentTerritory(); + this.updateMobileUnitFogVisibility(); + if (this.hasDirtyChunks()) this.renderFog(); + } + + renderLayer(ctx: CanvasRenderingContext2D) { + if (this.game.config().gameConfig().gameMode !== GameMode.FogOfWar) return; + if (this.hasDirtyChunks()) this.renderFog(); + ctx.drawImage(this.fogCanvas, -this.fullW / 2, -this.fullH / 2, this.fullW, this.fullH); + } + + // UNITS AND BORDERS GIVE VISION + private async updateVision() { + // Clear the vision buffer completely on each update + // This ensures that only areas with active vision sources remain visible + this.visionBuffer.fill(255); + + const player = this._observedPlayer || this.game.myPlayer(); + if (!player) return; + + const allies = [player]; + this.game.playerViews().forEach(p => { + if (p.id() !== player.id() && player.isAlliedWith(p)) allies.push(p); + }); + + // Create a new cache to compare with the previous one + const newVisionCache: Map = new Map(); + const newBorderVisionCache: Map> = new Map(); + + // First apply vision from fixed units and borders + const fixedEntities: Array<{player: any, unit: any, radius: number}> = []; + const mobileEntities: Array<{player: any, unit: any, radius: number}> = []; + + // Separate fixed units from mobile ones + for (const p of allies) { + for (const unit of p.units()) { + const radius = this.getUnitVisionRange(unit); + const unitType = unit.type?.() || ""; + const isFixed = ["City", "Port", "Defense Post", "Missile Silo", "SAM Launcher", "Factory"].includes(unitType); + + if (isFixed) { + fixedEntities.push({player: p, unit, radius}); + } else { + mobileEntities.push({player: p, unit, radius}); + } + } + } + + // Apply vision from fixed units first + for (const {player, unit, radius} of fixedEntities) { + const unitKey = `${player.id()}-${unit.id()}`; + newVisionCache.set(unitKey, {centerTile: unit.tile(), radius, type: 'fixed'}); + this.applyVisionCircle(unit.tile(), radius); + } + + // Apply vision from borders + await this.applyBorderVision(allies, newBorderVisionCache); + + // Now apply vision from mobile units only where necessary + for (const {player, unit, radius} of mobileEntities) { + const unitKey = `${player.id()}-${unit.id()}`; + newVisionCache.set(unitKey, {centerTile: unit.tile(), radius, type: 'mobile'}); + + // Check if the mobile unit can reveal at least 25% of its vision range + const tileRef = unit.tile(); + const canActivateVision = this.canUnitActivateVision(tileRef, radius); + + // Activate unit vision if it can reveal 25% or more of its field + if (canActivateVision) { + this.applyVisionCircle(unit.tile(), radius); + } + } + + // Update caches + this.visionCache = newVisionCache; + this.borderVisionCache = newBorderVisionCache; + + this.applyVisionToFogMap(); + } + + // New method to apply vision from border tiles + private async applyBorderVision(alliedPlayers: any[], newBorderVisionCache: Map>) { + // Process all borders at once to avoid inconsistencies + const borderData: Array<{playerId: string, borderTiles: number[]}> = []; + + // Collect all borders first + for (const player of alliedPlayers) { + try { + const borderTiles = await player.borderTiles(); + borderData.push({ + playerId: player.id(), + borderTiles: borderTiles.borderTiles + }); + } catch (e) { + // In case of error, continue with other players + console.warn("Failed to get border tiles for player", player.id(), e); + } + } + + // Now apply vision from borders + for (const {playerId, borderTiles} of borderData) { + newBorderVisionCache.set(playerId, new Set(borderTiles)); + + // Apply vision from borders only where necessary + for (const tile of borderTiles) { + // Check if the border is in an area already visible + const lx = Math.floor(this.game.x(tile) / this.scale); + const ly = Math.floor(this.game.y(tile) / this.scale); + const idx = ly * this.lowW + lx; + + // If the area is not visible (buffer is still 255), apply vision from the border + if (this.visionBuffer[idx] === 255) { + this.applyVisionCircle(tile, 20); // Fixed radius of 20 tiles for border vision + } + // If the area is already visible, we don't need to apply additional vision + } + } + } + + private applyVisionCircle(centerTile: number, radius: number) { + const cx = this.game.x(centerTile); + const cy = this.game.y(centerTile); + const r2 = radius * radius; + + for (let dy = -radius; dy <= radius; dy++) { + const wy = cy + dy; + if (wy < 0 || wy >= this.fullH) continue; + const maxDx = Math.floor(Math.sqrt(r2 - dy * dy)); + + for (let dx = -maxDx; dx <= maxDx; dx++) { + const wx = cx + dx; + if (wx < 0 || wx >= this.fullW) continue; + + const lx = Math.floor(wx / this.scale); + const ly = Math.floor(wy / this.scale); + if (lx >= 0 && lx < this.lowW && ly >= 0 && ly < this.lowH) { + this.visionBuffer[ly * this.lowW + lx] = 0; + } + } + } + } + + + + private applyVisionToFogMap() { + let changed = false; + for (let i = 0; i < this.fogMap.length; i++) { + // Check if this tile is owned by a player + const lowX = i % this.lowW; + const lowY = Math.floor(i / this.lowW); + const fullX = lowX * this.scale; + const fullY = lowY * this.scale; + + // Get the owner of this tile + const ownerID = this.getTileOwnerID(fullX, fullY); + const myPlayer = this._observedPlayer || this.game.myPlayer(); + + // Skip processing for player's own territory tiles + if (ownerID !== 0 && myPlayer && ownerID === myPlayer.smallID()) { + // Player's own territory tiles never go to fog 0.8 + if (this.fogMap[i] !== 0) { + this.fogMap[i] = 0; + this.markTileDirty(i); + changed = true; + } + continue; + } + + // Handle ally territory tiles - they should always be fog 0 as long as they are still allies + if (ownerID !== 0 && myPlayer && this.isAlly(ownerID, myPlayer)) { + // Ally's territory tiles should always be fog 0 + if (this.fogMap[i] !== 0) { + this.fogMap[i] = 0; + this.markTileDirty(i); + changed = true; + } + continue; + } + + if (this.territoryMap[i]) continue; + + const newFog = this.visionBuffer[i] === 0 ? 0 : (this.fogMap[i] <= 204 ? 204 : 255); + if (this.fogMap[i] !== newFog) { + this.fogMap[i] = newFog; + this.markTileDirty(i); + changed = true; + } + } + if (changed) this.renderFog(); + } + + private applyPermanentTerritory() { + let changed = false; + for (let i = 0; i < this.territoryMap.length; i++) { + if (this.territoryMap[i] && this.fogMap[i] !== 0) { + this.fogMap[i] = 0; + this.markTileDirty(i); + changed = true; + } + } + if (changed) this.renderFog(); + } + + public claimTerritory(x: number, y: number) { + if (x < 0 || y < 0 || x >= this.fullW || y >= this.fullH) return; + const fullIdx = y * this.fullW + x; + const lowIdx = this.fullToLow(fullIdx); + this.territoryMap[lowIdx] = 1; + this.fogMap[lowIdx] = 0; + this.markTileDirty(lowIdx); + } + + private renderFog() { + const data = this.fogImageData.data; + for (const chunk of this.chunks) { + if (!chunk.isDirty) continue; + for (let y = chunk.startY; y < chunk.endY; y++) { + for (let x = chunk.startX; x < chunk.endX; x++) { + const i = y * this.lowW + x; + const p = i * 4; + + // Check if this tile is owned by a player + const fullX = x * this.scale; + const fullY = y * this.scale; + + // Get the owner of this tile + const ownerID = this.getTileOwnerID(fullX, fullY); + const myPlayer = this._observedPlayer || this.game.myPlayer(); + + if (this.territoryMap[i]) { + data[p] = 0; data[p+1] = 128; data[p+2] = 0; data[p+3] = 70; + } else if (ownerID !== 0) { + // This tile is owned by some player + // Territory tiles always have fog 0, even if not in vision range + if (myPlayer && (ownerID === myPlayer.smallID() || this.isAlly(ownerID, myPlayer))) { + // Player's own territory or ally's territory - no fog (fog 0) + data[p] = data[p+1] = data[p+2] = data[p+3] = 0; + } else { + // Enemy territory - also fog 0 (no fog) as per requirement + data[p] = data[p+1] = data[p+2] = data[p+3] = 0; + } + } else { + // No owner (neutral territory) - normal fog behavior + data[p] = data[p+1] = data[p+2] = data[p+3] = 0; + } + + const fog = this.fogMap[i]; + // Player's own territory tiles and ally territory tiles never show fog 0.8 + if (ownerID !== 0 && myPlayer && (ownerID === myPlayer.smallID() || this.isAlly(ownerID, myPlayer))) { + // Keep fog at 0 for player's own territory and ally territory + data[p+3] = 0; + } else if (fog >= 204) { + const gray = 12 + (i & 15); + data[p] = gray; data[p+1] = gray; data[p+2] = gray; + data[p+3] = fog; + } + } + } + chunk.isDirty = false; + } + this.fogCtx.putImageData(this.fogImageData, 0, 0); + } + + private fullToLow(fullIdx: number): number { + const x = fullIdx % this.fullW; + const y = Math.floor(fullIdx / this.fullW); + const lx = Math.floor(x / this.scale); + const ly = Math.floor(y / this.scale); + return Math.min(ly, this.lowH - 1) * this.lowW + Math.min(lx, this.lowW - 1); + } + + private markTileDirty(lowIdx: number) { + const x = lowIdx % this.lowW; + const y = Math.floor(lowIdx / this.lowW); + const cx = Math.floor(x / this.CHUNK_SIZE); + const cy = Math.floor(y / this.CHUNK_SIZE); + const idx = cy * Math.ceil(this.lowW / this.CHUNK_SIZE) + cx; + if (idx < this.chunks.length) this.chunks[idx].isDirty = true; + } + + private markAllDirty() { this.chunks.forEach(c => c.isDirty = true); } + private hasDirtyChunks(): boolean { return this.chunks.some(c => c.isDirty); } + + public getFogValueAt(fullIdx: number): number { + const low = this.fullToLow(fullIdx); + return this.fogMap[low] / 255; + } + + // Helper method to get tile owner ID + private getTileOwnerID(x: number, y: number): number { + // Convert screen coordinates to game coordinates + const gameX = Math.floor(x); + const gameY = Math.floor(y); + + // Check if coordinates are valid + if (gameX < 0 || gameX >= this.game.width() || gameY < 0 || gameY >= this.game.height()) { + return 0; + } + + // Get the tile reference + const tileRef = this.game.ref(gameX, gameY); + + // Return the owner ID + return this.game.ownerID(tileRef); + } + + // Helper method to check if a player ID belongs to an ally + private isAlly(ownerID: number, myPlayer: any): boolean { + if (ownerID === myPlayer.smallID()) { + return true; // Own player is considered an ally + } + + // Check if the owner is an ally + const ownerPlayer = this.game.playerBySmallID(ownerID); + if (ownerPlayer && ownerPlayer.isPlayer()) { + return myPlayer.isAlliedWith(ownerPlayer); + } + + return false; + } + + // Method to check if a unit can reveal at least 25% of its vision field + private canUnitActivateVision(centerTile: number, radius: number): boolean { + const cx = this.game.x(centerTile); + const cy = this.game.y(centerTile); + const r2 = radius * radius; + + let totalTiles = 0; + let visibleTiles = 0; + + // Count how many tiles of the vision field are already visible vs total + for (let dy = -radius; dy <= radius; dy++) { + const wy = cy + dy; + if (wy < 0 || wy >= this.fullH) continue; + const maxDx = Math.floor(Math.sqrt(r2 - dy * dy)); + + for (let dx = -maxDx; dx <= maxDx; dx++) { + const wx = cx + dx; + if (wx < 0 || wx >= this.fullW) continue; + + const lx = Math.floor(wx / this.scale); + const ly = Math.floor(wy / this.scale); + if (lx >= 0 && lx < this.lowW && ly >= 0 && ly < this.lowH) { + totalTiles++; + const idx = ly * this.lowW + lx; + // Count as visible if the buffer is 0 (fog 0) + if (this.visionBuffer[idx] === 0) { + visibleTiles++; + } + } + } + } + + + const visibilityRatio = visibleTiles / totalTiles; + return visibilityRatio < 0.75; + } + + private getUnitVisionRange(unit: any): number { + const type = unit.type?.() || ""; + const level = unit.level?.() ?? 1; + const boost = 1 + (level - 1) * 0.2; + const base: Record = { + "City": 30, "Port": 80, "Defense Post": 70, "Warship": 140, + "Missile Silo": 200, "SAM Launcher": 400, "Factory": 35 + }; + const range = base[type] ?? 15; + const upgradable = ["City", "Port", "Missile Silo", "SAM Launcher", "Factory"]; + return upgradable.includes(type) ? Math.round(range * boost) : range; + } + + private handleAllianceChanges() { + const player = this._observedPlayer || this.game.myPlayer(); + if (!player) return; + + // Create current allies set + const currentAllies = new Set(); + currentAllies.add(player.id()); + + this.game.playerViews().forEach(p => { + if (p.id() !== player.id() && player.isAlliedWith(p)) { + currentAllies.add(p.id()); + } + }); + + // Check for alliance changes + const oldAlliesArray = Array.from(this.previousAllies); + const newAlliesArray = Array.from(currentAllies); + + // If alliance composition changed, we could add logic here if needed + if (oldAlliesArray.length !== newAlliesArray.length || + oldAlliesArray.some(id => !currentAllies.has(id)) || + newAlliesArray.some(id => !this.previousAllies.has(id))) { + + // Update the previous allies set + this.previousAllies = currentAllies; + } + } + + public setObservedPlayer(player: any) { + this._observedPlayer = player; + this.fogMap.fill(255); + this.visionBuffer.fill(255); + this.territoryMap.fill(0); + this.markAllDirty(); + + // Clear caches when the observed player changes + this.visionCache.clear(); + this.borderVisionCache.clear(); + + + // Reset alliance tracking + this.previousAllies.clear(); + if (player) { + this.previousAllies.add(player.id()); + this.game.playerViews().forEach(p => { + if (p.id() !== player.id() && player.isAlliedWith(p)) { + this.previousAllies.add(p.id()); + } + }); + } + } + + private updateMobileUnitFogVisibility() { + // This method is now simplified since we're not tracking units with distance anymore + // The visibility is now determined in real-time based on fog level + } + + // Method to check if a mobile unit should be rendered as opacued or invisible + public getMobileUnitFogEffect(unitId: number): { isOpacued: boolean, isInvisible: boolean } { + // Find the unit in the game + const unit = this.game.unit(unitId); + if (!unit) { + // Unit doesn't exist, should be visible (or not rendered at all) + return { isOpacued: false, isInvisible: false }; + } + + const player = this._observedPlayer || this.game.myPlayer(); + if (!player) { + return { isOpacued: false, isInvisible: false }; + } + + // Check if unit owner is an ally + const unitOwner = unit.owner?.(); + if (unitOwner && player.isAlliedWith(unitOwner)) { + // Units from allies should always be visible + return { isOpacued: false, isInvisible: false }; + } + + // Get the fog value at the unit's position + const unitTile = unit.tile(); + const unitX = this.game.x(unitTile); + const unitY = this.game.y(unitTile); + const lowX = Math.floor(unitX / this.scale); + const lowY = Math.floor(unitY / this.scale); + + if (lowX >= 0 && lowX < this.lowW && lowY >= 0 && lowY < this.lowH) { + const idx = lowY * this.lowW + lowX; + const fogValue = this.fogMap[idx]; + + // If fog is 0.8 or higher (204/255), the mobile unit should be opacued + if (fogValue >= this.MOBILE_FOG_THRESHOLD) { + return { isOpacued: true, isInvisible: false }; + } + } + + return { isOpacued: false, isInvisible: false }; + } + + // Method to check if a fixed unit should be rendered based on fog level + public getFixedUnitFogVisibility(unit: any): { isVisible: boolean } { + const player = this._observedPlayer || this.game.myPlayer(); + if (!player) { + return { isVisible: true }; + } + + // Check if unit owner is an ally + const unitOwner = unit.owner?.(); + if (unitOwner && player.isAlliedWith(unitOwner)) { + // Units from allies should always be visible + return { isVisible: true }; + } + + // Get the fog value at the unit's position + const unitTile = unit.tile(); + const unitX = this.game.x(unitTile); + const unitY = this.game.y(unitTile); + const lowX = Math.floor(unitX / this.scale); + const lowY = Math.floor(unitY / this.scale); + + if (lowX >= 0 && lowX < this.lowW && lowY >= 0 && lowY < this.lowH) { + const idx = lowY * this.lowW + lowX; + const fogValue = this.fogMap[idx]; + + // If fog is 0.8 or higher (204/255), the fixed unit should not be visible + if (fogValue >= this.MOBILE_FOG_THRESHOLD) { + return { isVisible: false }; + } + } + + return { isVisible: true }; + } + + public clearObservedPlayer() { this._observedPlayer = null; } +} \ No newline at end of file diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 3204d1e56..6369ab5dd 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -3,9 +3,11 @@ import { customElement, property, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; import { renderTroops, translateText } from "../../../client/Utils"; import { EventBus, GameEvent } from "../../../core/EventBus"; +import { GameMode } from "../../../core/game/Game"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { renderNumber } from "../../Utils"; import { Layer } from "./Layer"; +import { FogOfWarLayer } from "./FogOfWarLayer"; interface Entry { name: string; @@ -33,12 +35,23 @@ export class GoToUnitEvent implements GameEvent { constructor(public unit: UnitView) {} } +// Event to view another player's vision (for eliminated players) +export class ViewPlayerVisionEvent implements GameEvent { + constructor(public player: PlayerView) {} +} + @customElement("leader-board") export class Leaderboard extends LitElement implements Layer { public game: GameView | null = null; public eventBus: EventBus | null = null; + /** + * Referência à camada de Fog of War para controlar visibilidade no leaderboard + */ + public fogOfWarLayer: FogOfWarLayer | null = null; // Reference to FogOfWarLayer players: Entry[] = []; + // Track players that have been seen in fog 0 in local mode + private seenPlayersInLocalMode = new Set(); @property({ type: Boolean }) visible = false; private showTopFive = true; @@ -48,12 +61,22 @@ export class Leaderboard extends LitElement implements Layer { @state() private _sortOrder: "asc" | "desc" = "desc"; + + // Leaderboard mode: 'local' for visible only, 'global' for all players + @state() + private _leaderboardMode: "local" | "global" = "local"; + + // Track which player's vision is currently being viewed + private viewingPlayerVision: PlayerView | null = null; createRenderRoot() { return this; // use light DOM for Tailwind support } - init() {} + init() { + // Clear the seen players when initializing a new game + this.seenPlayersInLocalMode.clear(); + } tick() { if (this.game === null) throw new Error("Not initialized"); @@ -72,6 +95,83 @@ export class Leaderboard extends LitElement implements Layer { } this.updateLeaderboard(); } + + // Check if the player can access the global leaderboard mode + private canAccessGlobalMode(): boolean { + // In Fog of War mode, only eliminated players can access global mode + if (this.game?.config().gameConfig().gameMode === GameMode.FogOfWar) { + const myPlayer = this.game.myPlayer(); + return myPlayer !== null && !myPlayer.isAlive(); + } + + // In other modes, everyone can access global mode + return true; + } + + // Method to set the player whose vision we want to view + public setViewingPlayerVision(player: PlayerView | null) { + // If we have a FogOfWarLayer reference, update it + if (this.fogOfWarLayer && typeof this.fogOfWarLayer.setObservedPlayer === 'function') { + this.fogOfWarLayer.setObservedPlayer(player); + } + + // Update our local tracking + this.viewingPlayerVision = player; + } + + // Method to get the currently viewed player + public getViewingPlayerVision(): PlayerView | null { + return this.viewingPlayerVision; + } + + // Toggle between leaderboard modes + private toggleLeaderboardMode() { + // Check if the player can access global mode + if (this.game?.config().gameConfig().gameMode === GameMode.FogOfWar) { + if (!this.canAccessGlobalMode() && this._leaderboardMode === "local") { + // If the player is alive and trying to switch to global mode, don't allow + return; + } + } + + if (this.game?.config().gameConfig().gameMode === GameMode.FogOfWar) { + this._leaderboardMode = this._leaderboardMode === "local" ? "global" : "local"; + this.updateLeaderboard(); + } + } + + // Check if a player is visible in Fog of War mode + private isPlayerVisible(player: PlayerView): boolean { + // If we're not in Fog of War mode, all players are visible + if (!this.game || this.game.config().gameConfig().gameMode !== GameMode.FogOfWar || !this.fogOfWarLayer) { + return true; + } + + // If the player is eliminated, they are not visible in the normal leaderboard + if (!player.isAlive()) { + return false; + } + + // Get the player's position + const nameLocation = player.nameLocation(); + if (!nameLocation) { + return false; + } + + const x = nameLocation.x; + const y = nameLocation.y; + + // Check if coordinates are valid + 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); + + // Consider visible only if fog is 0 (completely visible area) + return fogValue === 0; + } + + return false; + } private updateLeaderboard() { if (this.game === null) throw new Error("Not initialized"); @@ -102,21 +202,105 @@ export class Leaderboard extends LitElement implements Layer { const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout(); - const alivePlayers = sorted.filter((player) => player.isAlive()); + // In Fog of War mode, filter players based on visibility and player state + let filteredPlayers = sorted; + if (this.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + if (this._leaderboardMode === "local") { + // Local mode: show all players but hide information for non-visible players + // Always include my player and allies + filteredPlayers = sorted.filter((player) => { + // Always show my player + if (myPlayer && player === myPlayer) { + return true; + } + + // Always show allies + if (myPlayer && myPlayer.isAlliedWith(player)) { + return true; + } + + // For others, check if they are alive and have been seen before + if (player.isAlive()) { + // Check if player has been seen in fog 0 before + if (!this.seenPlayersInLocalMode.has(player.id())) { + // Only add to seen players if currently visible (fog 0) + if (this.isPlayerVisible(player)) { + this.seenPlayersInLocalMode.add(player.id()); + return true; + } + return false; // Not visible and not seen before, don't add + } else { + // Player has been seen before, always include in local leaderboard + return true; + } + } else { + // Player is not alive, remove from seen players if they were there + this.seenPlayersInLocalMode.delete(player.id()); + return false; // Don't show dead players in local leaderboard + } + }); + } else { + // Global mode: check if the current player is eliminated + const isPlayerEliminated = myPlayer !== null && !myPlayer.isAlive(); + + // If the current player is eliminated, show all players + // If the current player is alive, show only visible alive players + if (isPlayerEliminated) { + // Eliminated players can see all players in global mode + filteredPlayers = sorted; + } else { + // Alive players can only see visible alive players in global mode + // But always include my player and allies + filteredPlayers = sorted.filter((player) => { + // Always show my player + if (myPlayer && player === myPlayer) { + return true; + } + + // Always show allies + if (myPlayer && myPlayer.isAlliedWith(player)) { + return true; + } + + // For others, check if they are alive and visible + return player.isAlive() && this.isPlayerVisible(player); + }); + } + } + } + + const alivePlayers = filteredPlayers.filter((player) => player.isAlive()); const playersToShow = this.showTopFive ? alivePlayers.slice(0, 5) : alivePlayers; this.players = playersToShow.map((player, index) => { - const maxTroops = this.game!.config().maxTroops(player); + + // In Fog of War local mode, hide player information if not visible + let playerName = player.displayName(); + let playerScore = formatPercentage(player.numTilesOwned() / numTilesWithoutFallout); + let playerGold = renderNumber(player.gold()); + const currentPlayerMaxTroops = this.game!.config().maxTroops(player); + let playerMaxTroops = renderTroops(currentPlayerMaxTroops || 0); + + if (this.game && this.game.config().gameConfig().gameMode === GameMode.FogOfWar && + this._leaderboardMode === "local" && + !this.isPlayerVisible(player) && + !(myPlayer && player === myPlayer) && + !(myPlayer && myPlayer.isAlliedWith(player))) { + // Hide information for non-visible players (except my player and allies) + playerName = "?????"; + playerScore = "?????"; + playerGold = "?????"; + playerMaxTroops = "?????"; + } + return { - name: player.displayName(), + name: playerName, position: index + 1, - score: formatPercentage( - player.numTilesOwned() / numTilesWithoutFallout, - ), - gold: renderNumber(player.gold()), - maxTroops: renderTroops(maxTroops), + score: playerScore, + gold: playerGold, + maxTroops: playerMaxTroops, isMyPlayer: player === myPlayer, isOnSameTeam: myPlayer !== null && @@ -125,6 +309,7 @@ export class Leaderboard extends LitElement implements Layer { }; }); + // If it's my player and not in the list, add it if ( myPlayer !== null && this.players.find((p) => p.isMyPlayer) === undefined @@ -137,7 +322,15 @@ export class Leaderboard extends LitElement implements Layer { } } - if (myPlayer.isAlive()) { + // In Fog of War mode, only add my player if they are visible or if we are in global mode + // and the player is eliminated + const isPlayerEliminated = myPlayer !== null && !myPlayer.isAlive(); + const shouldAddMyPlayer = + this.game.config().gameConfig().gameMode !== GameMode.FogOfWar || + (this._leaderboardMode === "global" && isPlayerEliminated) || + this.isPlayerVisible(myPlayer); + + if (myPlayer.isAlive() && shouldAddMyPlayer) { const myPlayerMaxTroops = this.game!.config().maxTroops(myPlayer); this.players.pop(); this.players.push({ @@ -160,6 +353,22 @@ export class Leaderboard extends LitElement implements Layer { private handleRowClickPlayer(player: PlayerView) { if (this.eventBus === null) return; + + // In Fog of War mode, eliminated players can view other players' vision + if (this.game?.config().gameConfig().gameMode === GameMode.FogOfWar) { + const myPlayer = this.game.myPlayer(); + + // In local mode, only allow focus if the player's information is visible (not hidden) + if (this._leaderboardMode === "local" && + !this.isPlayerVisible(player) && + !(myPlayer && player === myPlayer) && + !(myPlayer && myPlayer.isAlliedWith(player))) { + // Don't allow focus when information is hidden + return; + } + } + + // Comportamento normal para jogadores vivos - apenas foca no jogador this.eventBus.emit(new GoToPlayerEvent(player)); } @@ -173,6 +382,11 @@ export class Leaderboard extends LitElement implements Layer { if (!this.visible) { return html``; } + + const isFogOfWarMode = this.game?.config().gameConfig().gameMode === GameMode.FogOfWar; + const myPlayer = this.game?.myPlayer(); + const isPlayerEliminated = myPlayer !== undefined && myPlayer !== null && !myPlayer.isAlive(); + return html`
e.preventDefault()} > + ${isFogOfWarMode ? html` +
+ ${this._leaderboardMode === "local" + ? translateText("leaderboard.local_mode") + : translateText("leaderboard.global_mode")} + ${isPlayerEliminated ? html` (${translateText("leaderboard.eliminated")})` : ""} +
+ ` : ""} +
#
-
+
${translateText("leaderboard.player")}
this.setSort("tiles")} > ${translateText("leaderboard.owned")} @@ -206,7 +427,7 @@ export class Leaderboard extends LitElement implements Layer { : ""}
this.setSort("gold")} > ${translateText("leaderboard.gold")} @@ -217,7 +438,7 @@ export class Leaderboard extends LitElement implements Layer { : ""}
this.setSort("maxtroops")} > ${translateText("leaderboard.maxtroops")} @@ -227,6 +448,11 @@ export class Leaderboard extends LitElement implements Layer { : "⬇️" : ""}
+ ${isFogOfWarMode && isPlayerEliminated ? html` +
+ ${translateText("leaderboard.view")} +
+ ` : ""}
${repeat( @@ -234,16 +460,25 @@ export class Leaderboard extends LitElement implements Layer { (p) => p.player.id(), (player) => html`
this.handleRowClickPlayer(player.player)} + : ""} ${isFogOfWarMode && isPlayerEliminated ? 'cursor-pointer' : 'cursor-pointer'}" + @click=${(e: Event) => { + // Only handle row click for non-eliminated players or when not in Fog of War mode + if (!isFogOfWarMode || !isPlayerEliminated) { + this.handleRowClickPlayer(player.player); + } + }} >
${player.position}
{ + e.stopPropagation(); + this.handleRowClickPlayer(player.player); + }} > ${player.name}
@@ -256,21 +491,60 @@ export class Leaderboard extends LitElement implements Layer {
${player.maxTroops}
+ ${isFogOfWarMode && isPlayerEliminated ? html` +
{ + e.stopPropagation(); + if (this.eventBus) { + // In Fog of War mode, eliminated players can view other players' vision + if (this.game?.config().gameConfig().gameMode === GameMode.FogOfWar) { + const myPlayer = this.game.myPlayer(); + if (myPlayer && !myPlayer.isAlive()) { + // Set the player whose vision we want to view + this.setViewingPlayerVision(player.player); + // Also emit event for other systems to handle + this.eventBus.emit(new ViewPlayerVisionEvent(player.player)); + + // Focus on the player's position on the map + const nameLocation = player.player.nameLocation(); + if (nameLocation) { + this.eventBus.emit(new GoToPositionEvent(nameLocation.x, nameLocation.y)); + } + } + } + } + }}> + 👁️ +
+ ` : ""}
`, )}
- +
+ + + ${isFogOfWarMode && isPlayerEliminated ? html` + + ` : ""} +
`; } } diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index 3151c6a48..5f16845cf 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -1,7 +1,7 @@ import { LitElement } from "lit"; import { customElement } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; -import { PlayerActions } from "../../../core/game/Game"; +import { GameMode, PlayerActions } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { TransformHandler } from "../TransformHandler"; @@ -32,6 +32,10 @@ export class MainRadialMenu extends LitElement implements Layer { private chatIntegration: ChatIntegration; private clickedTile: TileRef | null = null; + /** + * Referência à camada de Fog of War para controlar visibilidade no menu radial + */ + private fogOfWarLayer: any = null; // Reference to FogOfWarLayer constructor( private eventBus: EventBus, @@ -71,6 +75,16 @@ export class MainRadialMenu extends LitElement implements Layer { this.chatIntegration = new ChatIntegration(this.game, this.eventBus); } + + // Method to set the reference to FogOfWarLayer + public setFogOfWarLayer(fogLayer: any) { + this.fogOfWarLayer = fogLayer; + } + + // Method to set the reference to NameLayer (not used in this implementation but added for consistency) + public setNameLayer(nameLayer: any) { + // Not used in this implementation + } init() { this.radialMenu.init(); @@ -85,6 +99,13 @@ export class MainRadialMenu extends LitElement implements Layer { if (this.game.myPlayer() === null) { return; } + + // In Fog of War mode, allow the radial menu in all areas + // Filtering which buttons to show will be done in rootMenuElement + if (this.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + // The logic for which buttons to show at each fog level + // is handled in rootMenuElement, so we allow the menu in all areas + } this.clickedTile = this.game.ref(worldCoords.x, worldCoords.y); this.game .myPlayer()! @@ -131,6 +152,7 @@ export class MainRadialMenu extends LitElement implements Layer { uiState: this.uiState, closeMenu: () => this.closeMenu(), eventBus: this.eventBus, + fogOfWarLayer: this.fogOfWarLayer, }; const isFriendlyTarget = diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 1c0b94a22..16591a62e 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -2,7 +2,7 @@ import { renderPlayerFlag } from "../../../core/CustomFlag"; import { EventBus } from "../../../core/EventBus"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; -import { Cell } from "../../../core/game/Game"; +import { Cell, GameMode } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent } from "../../InputHandler"; @@ -45,12 +45,18 @@ export class NameLayer implements Layer { private userSettings: UserSettings = new UserSettings(); private isVisible: boolean = true; private firstPlace: PlayerView | null = null; + private fogOfWarLayer: any = null; // Reference to FogOfWarLayer + /** + * @param fogOfWarLayer Referência opcional à camada de Fog of War para controlar visibilidade dos nomes dos jogadores + */ constructor( private game: GameView, private transformHandler: TransformHandler, private eventBus: EventBus, + fogOfWarLayer: any = null, // Optional reference to FogOfWarLayer ) { + this.fogOfWarLayer = fogOfWarLayer; this.shieldIconImage = new Image(); this.shieldIconImage.src = shieldIcon; this.shieldIconImage = new Image(); @@ -113,18 +119,111 @@ export class NameLayer implements Layer { if (!render.player.nameLocation() || !render.player.isAlive()) { return; } - + + // Check if we are in Fog of War mode and if the player is in a visible area + if (this.game.config().gameConfig().gameMode === GameMode.FogOfWar && this.fogOfWarLayer) { + // Get the player's position + const nameLocation = render.player.nameLocation(); + if (nameLocation) { + // Get the current player (myPlayer) + const myPlayer = this.game.myPlayer(); + + // Always show names of allies regardless of fog + if (myPlayer && myPlayer.isAlliedWith(render.player)) { + // Check other visibility conditions + const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size)); + const size = this.transformHandler.scale * baseSize; + const isOnScreen = render.location + ? this.transformHandler.isOnScreen(render.location) + : false; + const maxZoomScale = 17; + + if ( + !this.isVisible || + size < 7 || + (this.transformHandler.scale > maxZoomScale && size > 100) || + !isOnScreen + ) { + render.element.style.display = "none"; + } else { + render.element.style.display = "flex"; + } + return; + } + + // Check if myPlayer exists and has a name location + if (myPlayer && myPlayer.nameLocation()) { + const myNameLocation = myPlayer.nameLocation(); + // Calculate distance between the two players + const dx = nameLocation.x - myNameLocation.x; + const dy = nameLocation.y - myNameLocation.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // If within 15 tiles radius, the name is always visible + if (distance <= 15) { + // Check other visibility conditions + const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size)); + const size = this.transformHandler.scale * baseSize; + const isOnScreen = render.location + ? this.transformHandler.isOnScreen(render.location) + : false; + const maxZoomScale = 17; + + if ( + !this.isVisible || + size < 7 || + (this.transformHandler.scale > maxZoomScale && size > 100) || + !isOnScreen + ) { + render.element.style.display = "none"; + } else { + render.element.style.display = "flex"; + } + return; + } else { + // For non-allies, check fog value + const tileRef = this.game.ref(nameLocation.x, nameLocation.y); + const fogValue = this.fogOfWarLayer.getFogValueAt(tileRef); + + // If fog is 0.8 or higher (completely fogged), don't show the name + if (fogValue >= 0.8) { + render.element.style.display = "none"; + return; + } + } + } else { + // For non-allies, fallback to original fog check + const tileRef = this.game.ref(nameLocation.x, nameLocation.y); + const fogValue = this.fogOfWarLayer.getFogValueAt(tileRef); + + // If fog is 0.8 or higher (completely fogged), don't show the name + if (fogValue >= 0.8) { + render.element.style.display = "none"; + return; + } + } + + // Check if the player is an ally and has been eliminated + if (myPlayer && myPlayer.isAlliedWith(render.player) && !render.player.isAlive()) { + // Hide the name of eliminated allies + render.element.style.display = "none"; + return; + } + } + } + + // Regular visibility checks for non-Fog of War modes const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size)); const size = this.transformHandler.scale * baseSize; const isOnScreen = render.location ? this.transformHandler.isOnScreen(render.location) : false; const maxZoomScale = 17; - + if ( - !this.isVisible || - size < 7 || - (this.transformHandler.scale > maxZoomScale && size > 100) || + !this.isVisible || + size < 7 || + (this.transformHandler.scale > maxZoomScale && size > 100) || !isOnScreen ) { render.element.style.display = "none"; @@ -137,6 +236,27 @@ export class NameLayer implements Layer { if (this.game.ticks() % 10 !== 0) { return; } + + // Add all players normally in Fog of War mode + // Visibility will be controlled in updateElementVisibility + if (this.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + for (const player of this.game.playerViews()) { + if (player.isAlive() && !this.seenPlayers.has(player)) { + this.seenPlayers.add(player); + this.renders.push( + new RenderInfo( + player, + 0, + null, + 0, + "", + this.createPlayerElement(player), + ), + ); + } + } + return; + } // Precompute the first-place player for performance this.firstPlace = getFirstPlacePlayer(this.game); diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 56cfb3988..4b37b0b0c 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators.js"; import { renderPlayerFlag } from "../../../core/CustomFlag"; import { EventBus } from "../../../core/EventBus"; import { + GameMode, PlayerProfile, PlayerType, Relation, @@ -24,6 +25,7 @@ import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; import { CloseRadialMenuEvent } from "./RadialMenu"; +import { FogOfWarLayer } from "./FogOfWarLayer"; import allianceIcon from "/images/AllianceIcon.svg?url"; import warshipIcon from "/images/BattleshipIconWhite.svg?url"; import cityIcon from "/images/CityIconWhite.svg?url"; @@ -81,6 +83,18 @@ export class PlayerInfoOverlay extends LitElement implements Layer { private lastMouseUpdate = 0; private showDetails = true; + + // Track which players have been seen (visibility memory) + private seenPlayers: Set = new Set(); + + /** + * Referência à camada de Fog of War para verificação de visibilidade + */ + @property({ type: Object }) + public fogOfWarLayer: FogOfWarLayer | null = null; // Reference to FogOfWarLayer for visibility checking + + @property({ type: Object }) + public nameLayer: any = null; // Reference to NameLayer for visibility checking init() { this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) => @@ -121,11 +135,19 @@ export class PlayerInfoOverlay extends LitElement implements Layer { const owner = this.game.owner(tile); if (owner && owner.isPlayer()) { - this.player = owner as PlayerView; - this.player.profile().then((p) => { - this.playerProfile = p; - }); - this.setVisible(true); + const player = owner as PlayerView; + + // Check if player info should be visible based on fog of war + if (this.shouldShowPlayerInfo(player)) { + this.player = player; + this.player.profile().then((p) => { + this.playerProfile = p; + }); + this.setVisible(true); + + // Mark player as seen + this.seenPlayers.add(player.id()); + } } else if (!this.game.isLand(tile)) { const units = this.game .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip) @@ -139,6 +161,25 @@ export class PlayerInfoOverlay extends LitElement implements Layer { } } + // Check if player info should be shown based on fog of war visibility + private shouldShowPlayerInfo(player: PlayerView): boolean { + // If not in fog of war mode, show info + if (this.game.config().gameConfig().gameMode !== GameMode.FogOfWar) { + return true; + } + + // In fog of war mode, only show info if player is visible (fog 0 at name location) + const nameLocation = player.nameLocation(); + if (nameLocation && this.fogOfWarLayer) { + const idx = nameLocation.y * this.game.width() + nameLocation.x; + const fogValue = this.fogOfWarLayer.getFogValueAt(idx); + // Only show if fog is 0 (fully visible) + return fogValue === 0; + } + // If no fog layer available, default to showing info + return true; + } + tick() { this.requestUpdate(); } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 6f72f7149..a7c7218fb 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -1,5 +1,5 @@ import { Config } from "../../../core/configuration/Config"; -import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game"; +import { AllPlayers, GameMode, PlayerActions, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { Emoji, flattenedEmojiTable } from "../../../core/Util"; @@ -40,6 +40,10 @@ export interface MenuElementParams { eventBus: EventBus; uiState?: UIState; closeMenu: () => void; + /** + * Referência opcional à camada de Fog of War para controle de visibilidade no menu radial + */ + fogOfWarLayer?: any; } export interface MenuElement { @@ -117,6 +121,32 @@ function isFriendlyTarget(params: MenuElementParams): boolean { return isFriendly.call(selectedPlayer, params.myPlayer); } +// Helper function to check if a player is visible in Fog of War mode +function isPlayerVisibleInFog(params: MenuElementParams): boolean { + // This function should only be called in Fog of War mode + // If somehow called outside Fog of War mode, return true (visible) + if (params.game.config().gameConfig().gameMode !== GameMode.FogOfWar || !params.fogOfWarLayer) { + return true; + } + + // If no selected player, consider as not visible + if (!params.selected) { + return false; + } + + // Check if the selected player's name location is visible + const nameLocation = params.selected.nameLocation(); + if (!nameLocation) { + return false; + } + + const idx = nameLocation.y * params.game.width() + nameLocation.x; + const fogValue = params.fogOfWarLayer.getFogValueAt(idx); + + // Player is visible if fog value is less than 0.8 (consistent with NameLayer logic) + return fogValue < 0.8; +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars const infoChatElement: MenuElement = { id: "info_chat", @@ -251,10 +281,21 @@ const allyDonateTroopsElement: MenuElement = { const infoPlayerElement: MenuElement = { id: "info_player", name: "player", - disabled: () => false, + disabled: (params: MenuElementParams) => { + // Check if player is visible in Fog of War mode + if (!isPlayerVisibleInFog(params)) { + return true; // Disable if player is not visible + } + // Original condition - always enabled + return false; + }, color: COLORS.info, icon: infoIcon, action: (params: MenuElementParams) => { + // Check if player is visible in Fog of War mode + if (!isPlayerVisibleInFog(params)) { + return; // Don't show info panel if player is not visible + } params.playerPanel.show(params.playerActions, params.tile); }, }; @@ -263,7 +304,23 @@ const infoPlayerElement: MenuElement = { const infoEmojiElement: MenuElement = { id: "info_emoji", name: "emoji", - disabled: () => false, + disabled: (params: MenuElementParams) => { + // Check if we're in Fog of War mode + if (params.game.config().gameConfig().gameMode === GameMode.FogOfWar && params.fogOfWarLayer) { + // If we have a selected player, check if their name location is visible + if (params.selected) { + const nameLocation = params.selected.nameLocation(); + if (nameLocation) { + const idx = nameLocation.y * params.game.width() + nameLocation.x; + const fogValue = params.fogOfWarLayer.getFogValueAt(idx); + // Disable if fog value is 1 (completely hidden) + return fogValue >= 1.0; + } + } + } + // Original condition - always enabled + return false; + }, color: COLORS.infoEmoji, icon: emojiIcon, subMenu: (params: MenuElementParams) => { @@ -271,10 +328,42 @@ const infoEmojiElement: MenuElement = { { id: "emoji_more", name: "more", - disabled: () => false, + disabled: (params: MenuElementParams) => { + // Check if we're in Fog of War mode + if (params.game.config().gameConfig().gameMode === GameMode.FogOfWar && params.fogOfWarLayer) { + // If we have a selected player, check if their name location is visible + if (params.selected) { + const nameLocation = params.selected.nameLocation(); + if (nameLocation) { + const idx = nameLocation.y * params.game.width() + nameLocation.x; + const fogValue = params.fogOfWarLayer.getFogValueAt(idx); + // Disable if fog value is 1 (completely hidden) + return fogValue >= 1.0; + } + } + } + // Original condition - always enabled + return false; + }, color: COLORS.infoEmoji, icon: emojiIcon, action: (params: MenuElementParams) => { + // Check if we're in Fog of War mode and verify visibility + if (params.game.config().gameConfig().gameMode === GameMode.FogOfWar && params.fogOfWarLayer) { + // Check if the selected player's name location is visible + if (params.selected) { + const nameLocation = params.selected.nameLocation(); + if (nameLocation) { + const idx = nameLocation.y * params.game.width() + nameLocation.x; + const fogValue = params.fogOfWarLayer.getFogValueAt(idx); + // Only proceed if fog value is less than 1 (visible) + if (fogValue >= 1.0) { + params.closeMenu(); + return; // Don't show emoji table if player's name is not visible + } + } + } + } params.emojiTable.showTable((emoji) => { const targetPlayer = params.selected === params.game.myPlayer() @@ -296,9 +385,41 @@ const infoEmojiElement: MenuElement = { id: `emoji_${i}`, name: flattenedEmojiTable[i], text: flattenedEmojiTable[i], - disabled: () => false, + disabled: (params: MenuElementParams) => { + // Check if we're in Fog of War mode + if (params.game.config().gameConfig().gameMode === GameMode.FogOfWar && params.fogOfWarLayer) { + // If we have a selected player, check if their name location is visible + if (params.selected) { + const nameLocation = params.selected.nameLocation(); + if (nameLocation) { + const idx = nameLocation.y * params.game.width() + nameLocation.x; + const fogValue = params.fogOfWarLayer.getFogValueAt(idx); + // Disable if fog value is 1 (completely hidden) + return fogValue >= 1.0; + } + } + } + // Original condition - always enabled + return false; + }, fontSize: "25px", action: (params: MenuElementParams) => { + // Check if we're in Fog of War mode and verify visibility + if (params.game.config().gameConfig().gameMode === GameMode.FogOfWar && params.fogOfWarLayer) { + // Check if the selected player's name location is visible + if (params.selected) { + const nameLocation = params.selected.nameLocation(); + if (nameLocation) { + const idx = nameLocation.y * params.game.width() + nameLocation.x; + const fogValue = params.fogOfWarLayer.getFogValueAt(idx); + // Only proceed if fog value is less than 1 (visible) + if (fogValue >= 1.0) { + params.closeMenu(); + return; // Don't send emoji if player's name is not visible + } + } + } + } const targetPlayer = params.selected === params.game.myPlayer() ? AllPlayers @@ -316,11 +437,21 @@ const infoEmojiElement: MenuElement = { export const infoMenuElement: MenuElement = { id: Slot.Info, name: "info", - disabled: (params: MenuElementParams) => - !params.selected || params.game.inSpawnPhase(), + disabled: (params: MenuElementParams) => { + // Check if player is visible in Fog of War mode + if (!isPlayerVisibleInFog(params)) { + return true; // Disable if player is not visible + } + // Original condition + return !params.selected || params.game.inSpawnPhase(); + }, icon: infoIcon, color: COLORS.info, action: (params: MenuElementParams) => { + // Check if player is visible in Fog of War mode + if (!isPlayerVisibleInFog(params)) { + return; // Don't show info panel if player is not visible + } params.playerPanel.show(params.playerActions, params.tile); }, }; @@ -548,6 +679,20 @@ export const boatMenuElement: MenuElement = { color: COLORS.boat, action: async (params: MenuElementParams) => { + // Check if we are in Fog of War mode and if the position is completely fogged (fog = 1) + if (params.game.config().gameConfig().gameMode === GameMode.FogOfWar && params.fogOfWarLayer) { + const tileX = params.game.x(params.tile); + const tileY = params.game.y(params.tile); + const idx = tileY * params.game.width() + tileX; + const fogValue = params.fogOfWarLayer.getFogValueAt(idx); + + // If it's exactly fog = 1, don't send the boat attack + if (fogValue >= 1.0) { + params.closeMenu(); + return; + } + } + const spawn = await params.playerActionHandler.findBestTransportShipSpawn( params.myPlayer, params.tile, @@ -568,9 +713,32 @@ export const centerButtonElement: CenterButtonElement = { disabled: (params: MenuElementParams): boolean => { const tileOwner = params.game.owner(params.tile); const isLand = params.game.isLand(params.tile); - if (!isLand) { + + // If in spawn phase (loading screen) and random spawn is enabled, disable the center button + if (params.game.inSpawnPhase() && params.game.config().isRandomSpawn()) { return true; } + + // In Fog of War mode, allow the center button on sea tiles for the boat button + if (params.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + // No spawn phase (loading screen), disable the center button + if (params.game.inSpawnPhase()) { + return true; + } + + // Allow on sea tiles if the player has transport units available + if (!isLand) { + return !params.playerActions.buildableUnits.some( + (unit) => unit.type === UnitType.TransportShip && unit.canBuild + ); + } + } else { + // In other modes, disable on sea tiles as before + if (!isLand) { + return true; + } + } + if (params.game.inSpawnPhase()) { if (tileOwner.isPlayer()) { return true; @@ -585,6 +753,20 @@ export const centerButtonElement: CenterButtonElement = { return !params.playerActions.canAttack; }, action: (params: MenuElementParams) => { + // Check if we are in Fog of War mode and if the position is completely fogged (fog = 1) + if (params.game.config().gameConfig().gameMode === GameMode.FogOfWar && params.fogOfWarLayer) { + const tileX = params.game.x(params.tile); + const tileY = params.game.y(params.tile); + const idx = tileY * params.game.width() + tileX; + const fogValue = params.fogOfWarLayer.getFogValueAt(idx); + + // If it's exactly fog = 1, don't execute the center button action + if (fogValue >= 1.0) { + params.closeMenu(); + return; + } + } + if (params.game.inSpawnPhase()) { params.playerActionHandler.handleSpawn(params.tile); } else { @@ -616,6 +798,28 @@ export const rootMenuElement: MenuElement = { icon: infoIcon, color: COLORS.info, subMenu: (params: MenuElementParams) => { + // Check the fog value to determine which menus are available + let fogValue = 0; + let isFogLevel1 = false; // fog = 1 + + if (params.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + const tileX = params.game.x(params.tile); + const tileY = params.game.y(params.tile); + const idx = tileY * params.game.width() + tileX; + + // We need to access the FogOfWarLayer to get the fog value + // For now, we'll check if we can access it through the params + if ((params as any).fogOfWarLayer) { + const fogOfWarLayer = (params as any).fogOfWarLayer; + fogValue = fogOfWarLayer.getFogValueAt(idx); + + // Check if it's exactly fog = 1 + if (fogValue >= 1.0) { + isFogLevel1 = true; + } + } + } + let ally = allyRequestElement; if (params.selected?.isAlliedWith(params.myPlayer)) { ally = allyBreakElement; @@ -626,18 +830,23 @@ export const rootMenuElement: MenuElement = { tileOwner.isPlayer() && (tileOwner as PlayerView).id() === params.myPlayer.id(); - const menuItems: (MenuElement | null)[] = [ - infoMenuElement, - ...(isOwnTerritory - ? [deleteUnitElement, ally, buildMenuElement] - : [ - boatMenuElement, - ally, - isFriendlyTarget(params) - ? donateGoldRadialElement - : attackMenuElement, - ]), - ]; + const menuItems: (MenuElement | null)[] = []; + + // Specific logic based on fog value: + if (isFogLevel1) { + // Fog = 1: Only Attack Menu available + menuItems.push(attackMenuElement); + } else { + // All other fog values: All default menus available + menuItems.push(infoMenuElement); + + if (isOwnTerritory) { + menuItems.push(deleteUnitElement, ally, buildMenuElement); + } else { + menuItems.push(boatMenuElement, ally); + menuItems.push(isFriendlyTarget(params) ? donateGoldRadialElement : attackMenuElement); + } + } return menuItems.filter((item): item is MenuElement => item !== null); }, diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index d8edb3f02..cbdb3ac55 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -1,7 +1,7 @@ import { Colord } from "colord"; import { EventBus } from "../../../core/EventBus"; import { Theme } from "../../../core/configuration/Config"; -import { UnitType } from "../../../core/game/Game"; +import { UnitType, GameMode } from "../../../core/game/Game"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; @@ -9,6 +9,7 @@ import { UnitSelectionEvent } from "../../InputHandler"; import { ProgressBar } from "../ProgressBar"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { FogOfWarLayer } from "./FogOfWarLayer"; const COLOR_PROGRESSION = [ "rgb(232, 25, 25)", @@ -48,10 +49,14 @@ export class UILayer implements Layer { // Visual settings for selection private readonly SELECTION_BOX_SIZE = 6; // Size of the selection box (should be larger than the warship) + /** + * @param fogOfWarLayer Referência opcional à camada de Fog of War para controlar visibilidade de elementos de interface + */ constructor( private game: GameView, private eventBus: EventBus, private transformHandler: TransformHandler, + private fogOfWarLayer?: FogOfWarLayer, ) { this.theme = game.config().theme(); } @@ -270,6 +275,18 @@ export class UILayer implements Layer { if (maxHealth === undefined || this.context === null) { return; } + + // Check fog of war for Warship units + if (unit.type() === UnitType.Warship && this.fogOfWarLayer && this.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + const fogValue = this.fogOfWarLayer.getFogValueAt(unit.tile()); + if (fogValue >= 0.8) { + // Don't draw health bar if unit is in fog 0.8 or higher + this.allHealthBars.get(unit.id())?.clear(); + this.allHealthBars.delete(unit.id()); + return; + } + } + if ( this.allHealthBars.has(unit.id()) && (unit.health() >= maxHealth || unit.health() <= 0 || !unit.isActive()) @@ -356,6 +373,27 @@ export class UILayer implements Layer { if (!this.context) { return; } + + // Check fog of war for fixed structures + const fixedStructures = [ + UnitType.City, + UnitType.Factory, + UnitType.Port, + UnitType.DefensePost, + UnitType.MissileSilo, + UnitType.SAMLauncher + ]; + + if (fixedStructures.includes(unit.type()) && this.fogOfWarLayer && this.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + const fogValue = this.fogOfWarLayer.getFogValueAt(unit.tile()); + if (fogValue >= 0.8) { + // Don't draw loading bar if fixed structure is in fog 0.8 or higher + this.allProgressBars.get(unit.id())?.progressBar.clear(); + this.allProgressBars.delete(unit.id()); + return; + } + } + if (!this.allProgressBars.has(unit.id())) { const progressBar = new ProgressBar( COLOR_PROGRESSION, diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index ca1de78e7..b73212892 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -1,7 +1,7 @@ import { colord, Colord } from "colord"; import { EventBus } from "../../../core/EventBus"; import { Theme } from "../../../core/configuration/Config"; -import { UnitType } from "../../../core/game/Game"; +import { GameMode, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, UnitView } from "../../../core/game/GameView"; import { BezenhamLine } from "../../../core/utilities/Line"; @@ -14,6 +14,7 @@ import { } from "../../InputHandler"; import { MoveWarshipIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; +import { FogOfWarLayer } from "./FogOfWarLayer"; import { Layer } from "./Layer"; import { GameUpdateType } from "../../../core/game/GameUpdates"; @@ -51,10 +52,14 @@ export class UnitLayer implements Layer { // Configuration for unit selection private readonly WARSHIP_SELECTION_RADIUS = 10; // Radius in game cells for warship selection hit zone + /** + * @param fogOfWarLayer Referência opcional à camada de Fog of War para controlar visibilidade de unidades + */ constructor( private game: GameView, private eventBus: EventBus, transformHandler: TransformHandler, + private fogOfWarLayer?: FogOfWarLayer, ) { this.theme = game.config().theme(); this.transformHandler = transformHandler; @@ -338,7 +343,18 @@ export class UnitLayer implements Layer { private handleWarShipEvent(unit: UnitView) { if (unit.targetUnitId()) { - this.drawSprite(unit, colord("rgb(200,0,0)")); + // Check fog of war for Warship attack color + let attackColor = colord("rgb(200,0,0)"); // Default red color + + if (this.fogOfWarLayer && this.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + const fogValue = this.fogOfWarLayer.getFogValueAt(unit.tile()); + if (fogValue >= 0.8) { + // Dark blue opaque color when in fog 0.8 or higher + attackColor = colord("rgb(0,0,139)").alpha(0.7); // Dark blue with 70% opacity + } + } + + this.drawSprite(unit, attackColor); } else { this.drawSprite(unit); } @@ -384,12 +400,31 @@ export class UnitLayer implements Layer { private drawTrail(trail: number[], color: Colord, rel: Relationship) { // Paint new trail for (const t of trail) { + // Check fog of war for trail visibility + let alpha = 150; + if (this.fogOfWarLayer && this.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + const x = this.game.x(t); + const y = this.game.y(t); + const fullIdx = y * this.game.width() + x; + const fogValue = this.fogOfWarLayer.getFogValueAt(fullIdx); + + // If fog is 0.8 or higher, don't draw the trail + if (fogValue >= 0.8) { + continue; // Skip drawing this trail segment + } + // If fog is between 0 and 0.8, potentially adjust alpha + else if (fogValue > 0 && fogValue < 0.8) { + // Could apply partial opacity based on fog level + alpha = Math.floor(150 * (1 - fogValue)); + } + } + this.paintCell( this.game.x(t), this.game.y(t), rel, color, - 150, + alpha, this.unitTrailContext, ); } @@ -582,9 +617,47 @@ export class UnitLayer implements Layer { if (unit.isActive()) { const targetable = unit.targetable(); - if (!targetable) { + + // Apply fog of war effects + let fogEffectAlpha = 1.0; + let unitVisible = true; + + if (this.fogOfWarLayer && this.game.config().gameConfig().gameMode === GameMode.FogOfWar) { + // Check if this is a fixed unit (City, Port, Defense Post, Missile Silo, SAM Launcher, Factory) + const unitType = unit.type?.() || ""; + const isFixed = ["City", "Port", "Defense Post", "Missile Silo", "SAM Launcher", "Factory"].includes(unitType); + + if (isFixed) { + // For fixed units, check fog visibility directly + const fixedUnitVisibility = this.fogOfWarLayer.getFixedUnitFogVisibility(unit); + if (!fixedUnitVisibility.isVisible) { + unitVisible = false; + } + } else { + // For mobile units, use the existing logic + const fogEffect: any = this.fogOfWarLayer.getMobileUnitFogEffect(unit.id()); + if (fogEffect.isInvisible) { + // Unit is in fog 0.8 or higher, set opacity to 0.1 + fogEffectAlpha = 0.1; + } else if (fogEffect.isOpacued) { + // Unit should be opacued immediately + fogEffectAlpha = 0.1; + } + } + } + + // If unit is not visible based on fog, set its opacity to 0 + if (!unitVisible) { + fogEffectAlpha = 0; + } + + if (!targetable || fogEffectAlpha < 1.0) { this.context.save(); - this.context.globalAlpha = 0.5; + if (!targetable && fogEffectAlpha === 1.0) { + this.context.globalAlpha = 0.5; + } else { + this.context.globalAlpha = Math.min(0.5, fogEffectAlpha); + } } this.context.drawImage( sprite, @@ -593,7 +666,7 @@ export class UnitLayer implements Layer { sprite.width, sprite.width, ); - if (!targetable) { + if (!targetable || fogEffectAlpha < 1.0) { this.context.restore(); } } diff --git a/src/core/game/FogOfWar.ts b/src/core/game/FogOfWar.ts new file mode 100644 index 000000000..b98417cfb --- /dev/null +++ b/src/core/game/FogOfWar.ts @@ -0,0 +1,193 @@ +import { Game, Player, UnitType } from "./Game"; +import { GameMap, TileRef } from "./GameMap"; + +/** + * Classe responsável por gerenciar o sistema de Fog of War (nevoeiro de guerra) + * no jogo. Controla quais tiles são visíveis para cada jogador. + */ +/** + * Gerencia o sistema de Fog of War (nevoeiro de guerra) no jogo. + * Controla quais tiles são visíveis para cada jogador no modo Fog of War. + */ +export class FogOfWarManager { + private exploredTiles: Map> = new Map(); // playerId -> explored tiles + + constructor(private game: Game) {} + + /** + * Inicializa o sistema de Fog of War para todos os jogadores + */ + public initialize(): void { + // Para cada jogador, cria um conjunto vazio de tiles explorados + for (const player of this.game.players()) { + this.exploredTiles.set(player.id(), new Set()); + } + } + + /** + * Marca tiles como explorados para um jogador específico + * @param player O jogador que explorou os tiles + * @param tiles Os tiles que foram explorados + */ + public markAsExplored(player: Player, tiles: Set | TileRef[]): void { + const playerId = player.id(); + const exploredSet = this.exploredTiles.get(playerId); + + if (!exploredSet) { + console.warn(`No explored tiles set found for player ${playerId}`); + return; + } + + // Adiciona todos os tiles ao conjunto de explorados + for (const tile of tiles) { + exploredSet.add(tile); + + // Also marks the tile as explored on the map (for persistence) + const gameMap = this.game.map() as GameMap & { + setExplored?: (ref: TileRef, value: boolean) => void + }; + if (gameMap.setExplored) { + gameMap.setExplored(tile, true); + } + } + } + + /** + * Verifica se um tile é visível para um jogador + * @param player O jogador que está tentando ver o tile + * @param tile O tile a ser verificado + * @returns true se o tile é visível, false caso contrário + */ + public isVisible(player: Player, tile: TileRef): boolean { + const playerId = player.id(); + const exploredSet = this.exploredTiles.get(playerId); + + if (!exploredSet) { + return false; + } + + // Verifica se o tile foi explorado + if (exploredSet.has(tile)) { + return true; + } + + // Tiles adjacent to explored tiles are also visible + const neighbors = this.game.map().neighbors(tile); + for (const neighbor of neighbors) { + if (exploredSet.has(neighbor)) { + return true; + } + } + + return false; + } + + /** + * Obtém todos os tiles visíveis para um jogador + * @param player O jogador + * @returns Conjunto de tiles visíveis + */ + public getVisibleTiles(player: Player): Set { + const visibleTiles = new Set(); + const playerId = player.id(); + const exploredSet = this.exploredTiles.get(playerId); + + if (!exploredSet) { + return visibleTiles; + } + + // Adiciona todos os tiles explorados + for (const tile of exploredSet) { + visibleTiles.add(tile); + } + + // Adiciona tiles adjacentes aos explorados + const tempSet = new Set(); + for (const tile of exploredSet) { + const neighbors = this.game.map().neighbors(tile); + for (const neighbor of neighbors) { + if (!exploredSet.has(neighbor)) { + tempSet.add(neighbor); + } + } + } + + for (const tile of tempSet) { + visibleTiles.add(tile); + } + + return visibleTiles; + } + + /** + * Atualiza a visibilidade baseada nas unidades do jogador + * Deve ser chamado a cada turno + * @param player O jogador + */ + public updateVisibility(player: Player): void { + const visibleTiles = new Set(); + + // Adds tiles visible by the player's units + const units = player.units(); + for (const unit of units) { + const unitTile = unit.tile(); + visibleTiles.add(unitTile); + + // Adds adjacent tiles (basic vision range) + const neighbors = this.game.map().neighbors(unitTile); + for (const neighbor of neighbors) { + visibleTiles.add(neighbor); + } + + // Para unidades especiais como navios, pode ter range maior + if (unit.type() === "Warship" || unit.type() === "Transport") { + const extendedView = this.game.map().circleSearch(unitTile, 3); + for (const tile of extendedView) { + visibleTiles.add(tile); + } + } + } + + // Adiciona tiles das cidades do jogador + const cities = player.units(UnitType.City); + for (const city of cities) { + const cityTile = city.tile(); + visibleTiles.add(cityTile); + + // Cities have greater vision + const cityView = this.game.map().circleSearch(cityTile, 2); + for (const tile of cityView) { + visibleTiles.add(tile); + } + } + + // Marca os tiles como explorados + this.markAsExplored(player, visibleTiles); + } + + /** + * Verifica se dois jogadores podem ver tiles uns dos outros + * @param player1 Primeiro jogador + * @param player2 Segundo jogador + * @returns true se algum tile de um jogador é visível para o outro + */ + public canSeeEachOther(player1: Player, player2: Player): boolean { + // Checks if any tile of player2 is visible to player1 + const player2Tiles = player2.tiles(); + for (const tile of player2Tiles) { + if (this.isVisible(player1, tile)) { + return true; + } + } + + // Checks if any tile of player1 is visible to player2 + const player1Tiles = player1.tiles(); + for (const tile of player1Tiles) { + if (this.isVisible(player2, tile)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index ee0a7783a..4fd047266 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -4,6 +4,7 @@ import { AllPlayersStats, ClientID, Winner } from "../Schemas"; import { simpleHash } from "../Util"; import { AllianceImpl } from "./AllianceImpl"; import { AllianceRequestImpl } from "./AllianceRequestImpl"; +import { FogOfWarManager } from "./FogOfWar"; import { Alliance, AllianceRequest, @@ -81,6 +82,7 @@ export class GameImpl implements Game { private playerTeams: Team[]; private botTeam: Team = ColoredTeams.Bot; private _railNetwork: RailNetwork = createRailNetwork(this); + private fogOfWarManager?: FogOfWarManager; // Used to assign unique IDs to each new alliance private nextAllianceID: number = 0; @@ -100,6 +102,12 @@ export class GameImpl implements Game { this._height = _map.height(); this.unitGrid = new UnitGrid(this._map); + // Inicializa o sistema de Fog of War se estiver no modo correto + if (_config.gameConfig().gameMode === GameMode.FogOfWar) { + this.fogOfWarManager = new FogOfWarManager(this); + this.fogOfWarManager.initialize(); + } + if (_config.gameConfig().gameMode === GameMode.Team) { this.populateTeams(); } @@ -149,7 +157,7 @@ export class GameImpl implements Game { } private addPlayers() { - if (this.config().gameConfig().gameMode === GameMode.FFA) { + if (this.config().gameConfig().gameMode === GameMode.FFA || this.config().gameConfig().gameMode === GameMode.FogOfWar) { this._humans.forEach((p) => this.addPlayer(p)); this._nations.forEach((n) => this.addPlayer(n.playerInfo)); return; @@ -384,6 +392,9 @@ export class GameImpl implements Game { for (const player of this._players.values()) { // Players change each to so always add them this.addUpdate(player.toUpdate()); + + // Atualiza o Fog of War para este jogador + this.updateFogOfWar(player); } if (this.ticks() % 10 === 0) { this.addUpdate({ @@ -985,6 +996,38 @@ export class GameImpl implements Game { // Record stats this.stats().goldWar(conqueror, conquered, gold); } + + // Fog of War methods + /** + * Verifies if a tile is visible to a player in Fog of War mode. + * In other game modes, all tiles are considered visible. + * @param player The player whose visibility is being checked + * @param tile The tile reference to check visibility for + * @returns true if the tile is visible to the player, false otherwise + */ + isTileVisible(player: Player, tile: TileRef): boolean { + if (!this.fogOfWarManager) { + // If not in Fog of War mode, everything is visible + return true; + } + return this.fogOfWarManager.isVisible(player, tile); + } + + getVisibleTiles(player: Player): Set { + if (!this.fogOfWarManager) { + // If not in Fog of War mode, returns all tiles + const allTiles = new Set(); + this.map().forEachTile(tile => allTiles.add(tile)); + return allTiles; + } + return this.fogOfWarManager.getVisibleTiles(player); + } + + updateFogOfWar(player: Player): void { + if (this.fogOfWarManager) { + this.fogOfWarManager.updateVisibility(player); + } + } } // Or a more dynamic approach that will catch new enum values: