diff --git a/jest.config.ts b/jest.config.ts index bf8a05539..5628b26fe 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -10,6 +10,7 @@ export default { "^src/client/InputHandler$": "/tests/__mocks__/InputHandler.ts", "\\.(svg|png|jpe?g|gif|webp)$": "/tests/__mocks__/fileMock.ts", "^nanoid$": "/tests/__mocks__/nanoid.cjs", + "^bad-words$": "/tests/__mocks__/bad-words.ts", }, transform: { "^.+\\.tsx?$": ["@swc/jest"], diff --git a/package-lock.json b/package-lock.json index 2b9705f88..e7fb7803b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@opentelemetry/winston-transport": "^0.11.0", + "bad-words": "^4.0.0", "colord": "^2.9.3", "colorjs.io": "^0.5.2", "dompurify": "^3.1.7", @@ -8604,6 +8605,24 @@ "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, + "node_modules/bad-words": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bad-words/-/bad-words-4.0.0.tgz", + "integrity": "sha512-fLjG/I0N3I7xhurqGnGitSRD10UeEE63a7hyXtutQDpxo4+Eal+i7veWeZxZJPNtsl6X1mUIoWPwt8gQ7NMQUw==", + "license": "MIT", + "dependencies": { + "badwords-list": "^2.0.1-4" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/badwords-list": { + "version": "2.0.1-4", + "resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-2.0.1-4.tgz", + "integrity": "sha512-FxfZUp7B9yCnesNtFQS9v6PvZdxTYa14Q60JR6vhjdQdWI4naTjJIyx22JzoER8ooeT8SAAKoHLjKfCV7XgYUQ==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index 7f9e3ffad..aefb638c3 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@opentelemetry/winston-transport": "^0.11.0", + "bad-words": "^4.0.0", "colord": "^2.9.3", "colorjs.io": "^0.5.2", "dompurify": "^3.1.7", @@ -127,8 +128,8 @@ "prom-client": "^15.1.3", "protobufjs": "^7.3.2", "pureimage": "^0.4.13", - "sharp": "^0.34.2", "seedrandom": "^3.0.5", + "sharp": "^0.34.2", "ts-node": "^10.9.2", "uuid": "^11.1.0", "winston": "^3.17.0", diff --git a/proprietary/images/artillery-battery.png b/proprietary/images/artillery-battery.png new file mode 100644 index 000000000..98dc80d85 Binary files /dev/null and b/proprietary/images/artillery-battery.png differ diff --git a/resources/icons/research/air.svg b/resources/icons/research/air.svg new file mode 100644 index 000000000..bb48c2e87 --- /dev/null +++ b/resources/icons/research/air.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/research/land.svg b/resources/icons/research/land.svg new file mode 100644 index 000000000..95c8b841c --- /dev/null +++ b/resources/icons/research/land.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/research/nuclear.svg b/resources/icons/research/nuclear.svg new file mode 100644 index 000000000..9cc7e1f26 --- /dev/null +++ b/resources/icons/research/nuclear.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/research/sea.svg b/resources/icons/research/sea.svg new file mode 100644 index 000000000..7a1ef1897 --- /dev/null +++ b/resources/icons/research/sea.svg @@ -0,0 +1 @@ + diff --git a/resources/lang/en.json b/resources/lang/en.json index 497c28555..156b3fc6b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -98,6 +98,8 @@ "build_port_desc": "Can only be built near water. Allows building Cruisers. Automatically sends cargo ships between ports of your country and other countries (except when trade is stopped), giving gold to both sides. Trade stops automatically when you attack or are attacked by a player. It resumes after 5 minutes or if you become allies. You can manually toggle trading with \"Stop trading\" or \"Start trading\".", "build_warship": "Cruiser", "build_warship_desc": "Patrols in an area, capturing enemy cargo ships and destroying their Boats (transport ships) and Cruisers. Spawns from the nearest Port and patrols the area you first clicked to build it. You can control Cruisers by attack-clicking on them (see action Attack under Hotkeys) and then attack-clicking the new area you want them to move to.", + "build_artillery": "Artillery", + "build_artillery_desc": "Land-based heavy artillery that patrols an area and bombards enemy structures within range. Spawns from the nearest Factory and patrols the area you first clicked to build it. You can redirect Artillery by attack-clicking on them and then attack-clicking the new area you want them to move to.", "build_silo": "Missile Silo", "build_silo_desc": "Allows launching missiles.", "build_sam": "SAM Launcher", @@ -210,7 +212,9 @@ "teams_Duos": "Duos (teams of 2)", "teams_Trios": "Trios (teams of 3)", "teams_Quads": "Quads (teams of 4)", - "teams": "{num} teams" + "teams": "{num} teams", + "tech_unlocked": "Tech Unlocked", + "tech_unlocked_tooltip": "All technologies unlocked from the start!" }, "username": { "enter_username": "Enter your username", @@ -239,6 +243,7 @@ "infinite_gold_tooltip": "Never run out of gold", "infinite_troops": "Infinite Troops", "infinite_troops_tooltip": "Unlimited troop capacity", + "enable_chat": "Enable Lobby Chat", "peace_timer": "Protected Start", "peace_timer_tooltip": "Players cannot attack each other for this duration at game start", "peace_timer_none": "None", @@ -291,6 +296,7 @@ "fighter_jet": "Fast air unit for intercepting bombers and scouting.", "warship": "Patrols in an area, capturing enemy cargo ships and destroying their Boats and Cruisers.", "submarine": "Stealthy naval unit. Can launch nukes if researched. Invisible to enemies unless they have detection.", + "artillery": "Land-based heavy artillery that patrols an area and bombards enemy structures within range. Spawns from the nearest Factory.", "city": "Increases your max population. Useful when you can't expand your territory.", "port": "Allows building Cruisers and Submarines. Automatically sends cargo ships to trade with other players.", "airfield": "Automatically deploys bombers that fly out to strike enemy structures then return to base.", @@ -310,6 +316,7 @@ "defense_post": "Defense Post", "port": "Port", "warship": "Cruiser", + "artillery": "Artillery", "submarine": "Submarine", "missile_silo": "Missile Silo", "sam_launcher": "SAM Launcher", @@ -430,6 +437,8 @@ "build_warship_desc": "Set the hotkey to build a Cruiser.", "build_submarine": "Build Submarine", "build_submarine_desc": "Set the hotkey to build a Submarine.", + "build_artillery": "Build Artillery", + "build_artillery_desc": "Set the hotkey to build Artillery.", "reset": "Reset", "unbind": "Unbind", "on": "On", @@ -571,6 +580,7 @@ "missile_launchers": "Missile launchers", "sams": "SAMs", "warships": "Cruisers", + "artillery": "Artillery", "fighter_jets": "Fighter Jets", "health": "Health", "attitude": "Attitude", @@ -699,7 +709,10 @@ "international_trade_origin": "Your cargo truck successfully delivered goods to {destinationName}. You received {goldAmount} gold.", "international_trade_destination": "A cargo truck from {originName} arrived. You received {goldAmount} gold.", "insurance_refund": "Insurance refund {amount} gold.", - "insurance_refund_conquest": "Insurance refund {amount} gold for conquered structure." + "insurance_refund_conquest": "Insurance refund {amount} gold for conquered structure.", + "artillery_out_of_range_1": "Artillery is out of range (max 60 tiles for level 1)", + "artillery_out_of_range_2": "Artillery is out of range (max 75 tiles for level 2)", + "artillery_out_of_range_3": "Artillery is out of range (max 90 tiles for level 3)" }, "research_tree": { "title": "Research Tree", @@ -1023,6 +1036,13 @@ "desc": "''Requirement:'' Port
Generates gold by trading between your ports and other players' ports (not between your own ports). Passive income source. ''Tip:'' Protect trade routes from pirates and enemy submarines." } }, + "land": { + "title": "Land Units", + "artillery": { + "name": "Artillery", + "desc": "''Requirement:'' Factory
Land-based heavy artillery that patrols territory and bombards enemy structures within range. Spawns from nearest Factory. ''Range:'' 60/75/90 tiles (levels 1/2/3). ''Upgrades:'' Higher levels increase range, damage, and durability. ''Tip:'' Excellent for softening enemy defenses before ground assault." + } + }, "air": { "title": "Air Units", "bomber": { diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 33c77b7da..0210d937f 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -390,13 +390,6 @@ export class HelpModal extends LitElement { altKey: "ui_guide.command_center_economy_alt", icon: ``, }, - { - titleKey: "ui_guide.command_center_trade_title", - descKey: "ui_guide.command_center_trade_desc", - img: "/images/HelpModalScreenshots/CC-Trade.png", - altKey: "ui_guide.command_center_trade_alt", - icon: ``, - }, { titleKey: "ui_guide.command_center_diplomacy_title", descKey: "ui_guide.command_center_diplomacy_desc", @@ -889,6 +882,15 @@ export class HelpModal extends LitElement { }, ]; + const landUnits = [ + { + nameKey: "units.land.artillery.name", + iconClass: "artillery-icon", + hotkey: "4", + descKey: "units.land.artillery.desc", + }, + ]; + const airUnits = [ { nameKey: "units.air.bomber.name", @@ -978,6 +980,25 @@ export class HelpModal extends LitElement { +
+
+ ${this.t("units.land.title")} +
+ + + + + + + + + + + ${renderUnitRows(landUnits)} + +
${this.t("labels.name")}${this.t("labels.icon")}${this.t("labels.hotkey")}${this.t("labels.description")}
+
+
${this.t("units.air.title")} @@ -1153,7 +1174,6 @@ export class HelpModal extends LitElement { Sea: [], Air: [], Nuclear: [], - Economy: [], }; for (const node of nodes) { @@ -1188,7 +1208,6 @@ export class HelpModal extends LitElement { Sea: this.t("tech_tree.categories.sea"), Air: this.t("tech_tree.categories.air"), Nuclear: this.t("tech_tree.categories.nuclear"), - Economy: this.t("tech_tree.categories.economy"), }; const categoryDescriptions: Record = { @@ -1196,7 +1215,6 @@ export class HelpModal extends LitElement { Sea: this.t("tech_tree.categories.sea_desc"), Air: this.t("tech_tree.categories.air_desc"), Nuclear: this.t("tech_tree.categories.nuclear_desc"), - Economy: this.t("tech_tree.categories.economy_desc"), }; return html` diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index a75e9ed24..133ccada2 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -20,6 +20,7 @@ import type { ClientInfo, GameConfig, GameInfo, + LobbyMessage, TeamCountConfig, } from "../core/Schemas"; import { @@ -31,6 +32,7 @@ import { generateID } from "../core/Util"; import "./components/baseComponents/Modal"; import "./components/Difficulties"; import { DifficultyDescription } from "./components/Difficulties"; +import "./components/LobbyChatPanel"; import "./components/Maps"; import type { JoinLobbyEvent } from "./Main"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; @@ -77,6 +79,8 @@ export class HostLobbyModal extends LitElement { @state() private playerTeamAssignments: Record = {}; @state() private updatingTeamForClients: Set = new Set(); @state() private showUnitSettings = false; // Closed by default for Host + @state() private chatEnabled: boolean = false; + @state() private lobbyMessages: LobbyMessage[] = []; private playersInterval: NodeJS.Timeout | null = null; private botsUpdateTimer: number | null = null; @@ -97,6 +101,9 @@ export class HostLobbyModal extends LitElement { height: 75vh; overflow: hidden; } + .sp-layout.with-chat { + grid-template-columns: 1fr 1fr 280px; /* Add chat column */ + } @media (max-width: 1024px) { .sp-layout { @@ -105,11 +112,52 @@ export class HostLobbyModal extends LitElement { max-height: 85vh; overflow-y: auto; } + .sp-layout.with-chat { + grid-template-columns: 1fr; + } .sp-map-col { height: 40vh; } } + /* Chat Column (Right) */ + .sp-chat-col { + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + max-height: 75vh; + overflow: hidden; + } + .sp-chat-col .sp-title { + margin-bottom: 12px; + flex-shrink: 0; + } + .sp-chat-col lobby-chat-panel { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .sp-chat-col .lcp-container { + flex: 1; + display: flex; + flex-direction: column; + max-height: none; + height: 100%; + min-height: 0; + } + .sp-chat-col .lcp-messages { + flex: 1; + height: auto; + min-height: 200px; + overflow-y: auto; + } + .sp-chat-col .lcp-input-row { + flex-shrink: 0; + } + /* Map Column (Left) */ .sp-map-col { background: rgba(0, 0, 0, 0.2); @@ -590,7 +638,7 @@ export class HostLobbyModal extends LitElement { max-height="85vh" content-overflow="hidden" > -
+
@@ -944,6 +992,14 @@ export class HostLobbyModal extends LitElement { "host_modal.infinite_troops", this.handleInfiniteTroopsChange, )} + ${this.renderToggle( + this.chatEnabled, + "host_modal.enable_chat", + (e: any) => { + this.chatEnabled = e.target.checked; + this.putGameConfig(); + }, + )}
@@ -1014,6 +1070,21 @@ export class HostLobbyModal extends LitElement {
+ + + ${this.chatEnabled + ? html`
+
${translateText("chat")}
+ +
` + : nothing}
`; @@ -1514,6 +1585,7 @@ export class HostLobbyModal extends LitElement { peaceTimerDurationMinutes: this.selectedPeaceTimerDuration, startingGold: this.startingGold, goldMultiplier: this.goldMultiplier, + chatEnabled: this.chatEnabled, } satisfies Partial), }, ); @@ -1628,6 +1700,9 @@ export class HostLobbyModal extends LitElement { if (data.gameConfig?.researchAllTechs !== undefined) { this.researchAllTechs = Boolean(data.gameConfig.researchAllTechs); } + + // Update lobby messages + this.lobbyMessages = data.messages ?? []; }); } diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 84c382fb2..c8a3d993f 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -113,11 +113,18 @@ export class CenterCameraEvent implements GameEvent { import { UnitType } from "../core/game/Game"; import { GameView } from "../core/game/GameView"; +import { getArtilleryMaxDistance } from "../core/game/UnitUpgrades"; +import { + isUpgradeableUnit, + maxStructureLevel, + maxUnitLevel, + playerMaxUnitLevel, +} from "../core/game/Upgradeables"; import { ToggleBomberUpgradeModeEvent } from "./events/ToggleBomberUpgradeModeEvent"; import { ToggleUpgradeModeEvent } from "./events/ToggleUpgradeModeEvent"; import { TransformHandler } from "./graphics/TransformHandler"; import { UIState } from "./graphics/UIState"; -import { BuildUnitIntentEvent } from "./Transport"; +import { ArtilleryOutOfRangeEvent, BuildUnitIntentEvent } from "./Transport"; // Post-impact nuke halo event (world tile coordinates) export class NukeImpactEvent implements GameEvent { @@ -187,6 +194,7 @@ export class InputHandler { buildFighterJet: "Digit8", buildWarship: "Digit9", buildSubmarine: "Digit0", + buildArtillery: "Digit4", buildCity: "KeyY", buildPort: "KeyU", buildAirfield: "KeyI", @@ -379,6 +387,7 @@ export class InputHandler { [this.keybinds.buildFighterJet]: UnitType.FighterJet, [this.keybinds.buildWarship]: UnitType.Warship, [this.keybinds.buildSubmarine]: UnitType.Submarine, + [this.keybinds.buildArtillery]: UnitType.Artillery, [this.keybinds.buildCity]: UnitType.City, [this.keybinds.buildPort]: UnitType.Port, [this.keybinds.buildAirfield]: UnitType.Airfield, @@ -410,6 +419,14 @@ export class InputHandler { if (this.game.isValidCoord(cell.x, cell.y)) { const tile = this.game.ref(cell.x, cell.y); + + if ( + unitType === UnitType.Artillery && + !this.validateArtilleryBuildDistance(tile) + ) { + return; + } + this.eventBus.emit(new BuildUnitIntentEvent(unitType, tile)); } } @@ -499,6 +516,14 @@ export class InputHandler { this.uiState.bomberUpgradeMode = false; this.eventBus.emit(new ToggleBomberUpgradeModeEvent(false)); } + + if ( + this.uiState.pendingBuildUnitType === UnitType.Artillery && + !this.validateArtilleryBuildDistance(tile, true) + ) { + return; + } + this.eventBus.emit( new BuildUnitIntentEvent(this.uiState.pendingBuildUnitType, tile), ); @@ -635,6 +660,69 @@ export class InputHandler { }; } + private readArtilleryTargetLevel(): number { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return 1; + + // Default to player's max unlocked level + let targetLevel = playerMaxUnitLevel(myPlayer, UnitType.Artillery); + + try { + if (isUpgradeableUnit(UnitType.Artillery)) { + const rawUnits = localStorage.getItem("unitUpgradeSettings.levels"); + if (rawUnits) { + const obj = JSON.parse(rawUnits) as Record; + const val = obj?.[String(UnitType.Artillery)]; + if (typeof val === "number") { + targetLevel = Math.min(maxUnitLevel(UnitType.Artillery), val); + } + } + } else { + const rawStruct = localStorage.getItem("buildSettings.levels"); + if (rawStruct) { + const obj = JSON.parse(rawStruct) as Record; + const val = obj?.[String(UnitType.Artillery)]; + if (typeof val === "number") { + targetLevel = Math.min(maxStructureLevel(UnitType.Artillery), val); + } + } + } + } catch { + // Keep default (player's max unlocked level) + } + return targetLevel; + } + + private validateArtilleryBuildDistance( + tile: TileRef, + clearPendingIfInvalid: boolean = false, + ): boolean { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return true; + + const factories = myPlayer.units(UnitType.Factory); + if (factories.length === 0) return true; + + let minDistSq = Infinity; + for (const factory of factories) { + const distSq = this.game.euclideanDistSquared(factory.tile(), tile); + if (distSq < minDistSq) minDistSq = distSq; + } + + const targetLevel = this.readArtilleryTargetLevel(); + const maxDist = getArtilleryMaxDistance(targetLevel); + + if (minDistSq > maxDist * maxDist) { + this.eventBus.emit(new ArtilleryOutOfRangeEvent(targetLevel, maxDist)); + if (clearPendingIfInvalid && !this.uiState.multibuildEnabled) { + this.uiState.pendingBuildUnitType = null; + } + return false; + } + + return true; + } + destroy() { if (this.moveInterval !== null) { clearInterval(this.moveInterval); diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 01657a5c7..76b9011ec 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -6,6 +6,7 @@ import { ClientInfo, GameInfo, GameRecord, + LobbyMessage, TeamCountConfig, } from "../core/Schemas"; import { generateID } from "../core/Util"; @@ -13,6 +14,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { PastelTheme } from "../core/configuration/PastelTheme"; import { ColoredTeams, Duos, GameMode, Quads, Trios } from "../core/game/Game"; import { JoinLobbyEvent } from "./Main"; +import "./components/LobbyChatPanel"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; @customElement("join-private-lobby-modal") @@ -29,6 +31,10 @@ export class JoinPrivateLobbyModal extends LitElement { @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: TeamCountConfig = 2; @state() private startingGold: number | null = null; + @state() private chatEnabled: boolean = false; + @state() private lobbyMessages: LobbyMessage[] = []; + @state() private joinedClientID: string = ""; + @state() private joinedUsername: string = ""; private playersInterval: NodeJS.Timeout | null = null; private theme = new PastelTheme(); @@ -99,30 +105,49 @@ export class JoinPrivateLobbyModal extends LitElement {
${this.message}
-
- ${this.hasJoined && this.clients.length > 0 - ? html`
-
- ${this.clients.length} - ${this.clients.length === 1 - ? translateText("private_lobby.player") - : translateText("private_lobby.players")} -
-
- ${this.renderTeamColumns()} -
+
+
+
+ ${this.hasJoined && this.clients.length > 0 + ? html`
+
+ ${this.clients.length} + ${this.clients.length === 1 + ? translateText("private_lobby.player") + : translateText("private_lobby.players")} +
+
+ ${this.renderTeamColumns()} +
+
` + : ""} +
+
+ ${!this.hasJoined + ? html` ` + : ""} +
+
+ ${this.chatEnabled && this.hasJoined + ? html`
+
${translateText("chat")}
+
` : ""}
-
- ${!this.hasJoined - ? html` ` - : ""} -
`; } @@ -335,11 +360,18 @@ export class JoinPrivateLobbyModal extends LitElement { this.message = translateText("private_lobby.joined_waiting"); this.hasJoined = true; + const clientID = generateID(); + const usernameInput = document.querySelector("username-input") as any; + const username = usernameInput?.getCurrentUsername?.() ?? "Guest"; + + this.joinedClientID = clientID; + this.joinedUsername = username; + this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: lobbyId, - clientID: generateID(), + clientID: clientID, } as JoinLobbyEvent, bubbles: true, composed: true, @@ -425,10 +457,102 @@ export class JoinPrivateLobbyModal extends LitElement { this.gameMode = data.gameConfig.gameMode ?? GameMode.FFA; this.teamCount = data.gameConfig.playerTeams ?? 2; this.startingGold = data.gameConfig.startingGold ?? 0; + this.chatEnabled = data.gameConfig.chatEnabled ?? false; } + // Update lobby messages + this.lobbyMessages = data.messages ?? []; }) .catch((error) => { console.error("Error polling players:", error); }); } } + +// Inject styles for join lobby chat layout +const joinLobbyStyle = document.createElement("style"); +joinLobbyStyle.textContent = ` + .join-lobby-layout { + display: block; + } + .join-lobby-layout.with-chat { + display: flex; + gap: 16px; + align-items: stretch; + } + .join-lobby-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + } + .join-lobby-main .options-layout { + flex: 1; + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + } + .join-lobby-main .options-section { + flex: 1; + display: flex; + flex-direction: column; + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + padding: 12px; + margin: 0; + } + .join-lobby-main .option-title { + flex-shrink: 0; + font-weight: 600; + margin-bottom: 8px; + color: #fff; + } + .join-lobby-main .team-columns-container { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + } + .join-lobby-chat { + width: 260px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + padding: 12px; + max-height: 500px; + } + .join-lobby-chat .chat-title { + font-weight: 600; + margin-bottom: 8px; + color: #fff; + flex-shrink: 0; + } + .join-lobby-chat lobby-chat-panel { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + } + .join-lobby-chat .lcp-container { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + max-height: none; + min-height: 0; + } + .join-lobby-chat .lcp-messages { + flex: 1; + height: auto; + min-height: 120px; + overflow-y: auto; + } + .join-lobby-chat .lcp-input-row { + flex-shrink: 0; + margin-top: 8px; + } +`; +document.head.appendChild(joinLobbyStyle); diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index c6dd1df5a..b73b76636 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -31,6 +31,9 @@ export function startGame(id: GameID, lobby: Partial) { return; } + // Clear stack count settings so each game starts fresh + localStorage.removeItem("buildSettings.stackCount"); + _startTime = Date.now(); const stats = getStats(); stats[id] = { lobby }; diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index e4dd2bb37..6b064b1d9 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -127,6 +127,20 @@ export class PublicLobby extends LitElement { ${translateText("public_lobby.join")}
+ ${ + lobby.gameConfig.researchAllTechs + ? html` + ๐Ÿ”“ ${translateText("public_lobby.tech_unlocked")} + ` + : "" + } t.category)), - ) as Category[]; - private readonly tabOrder: ResearchTab[] = [ - "Land", - "Sea", - "Air", - "Nuclear", - "Economy", - "Policy Directives", - "Overview", - ]; - - @state() - private activeTab: ResearchTab = "Land"; - - @state() - private roadInvestmentRate = 0; + // Fixed category ordering: Land, Sea, Air, Nuclear + private categories: Category[] = ["Land", "Sea", "Air", "Nuclear"]; @state() private researchInvestmentRate = 0; - @state() - private lockRoad = false; - @state() private lockResearch = false; - @state() - private roadInvestmentEnabled = false; - connectedCallback(): void { super.connectedCallback(); window.addEventListener( @@ -108,12 +76,10 @@ export class ResearchTreeModal extends LitElement { open() { this.modalEl?.open(); this.requestInvestmentSync(); - // Perform a full layout pass on the next frame after opening - requestAnimationFrame(() => this.updateLayout()); - // Start a light refresh loop to reflect game state (gold/upgrades) while open this.refreshTimer ??= window.setInterval(() => this.requestUpdate(), 500); this.eventBus.on(CloseViewEvent, this.close); } + close = () => { this.modalEl?.close(); if (this.refreshTimer !== null) { @@ -122,148 +88,98 @@ export class ResearchTreeModal extends LitElement { } this.eventBus.off(CloseViewEvent, this.close); }; + show() { this.visible = true; this.open(); } + hide() { this.visible = false; this.close(); } - // Placeholder tree removed: client uses server-authoritative tree - private isAvailable(id: string, researched: Set): boolean { return serverIsTechAvailable(id, researched); } - // No mapping to existing UpgradeType; research tree is separate - private researchedIDsFromGame(): Set { const res = new Set(); const me = this.game?.myPlayer?.(); if (!me) return res; - // Use new per-match researched techs for (const t of this.techs) if (me.hasResearchedTech(t.id)) res.add(t.id); return res; } private onTechClick(id: string) { if (!this.game || !this.eventBus) return; - const tech = this.techs.find((t) => t.id === id)!; const me = this.game.myPlayer(); if (!me) return; const researched = this.researchedIDsFromGame(); - // Allow prioritizing even if unavailable; still ignore already researched - if (me.hasResearchedTech?.(id)) return; // already researched + if (me.hasResearchedTech?.(id)) return; + + // Find the clicked tech to get its level and category + const clickedTech = this.techs.find((t) => t.id === id); + if (!clickedTech) return; + + const priorities = me.researchPriorities?.() ?? new Set(); + + // If this tech is being prioritized (not toggled off) + const willBePrioritized = !priorities.has(id); + + if (willBePrioritized) { + // Remove priorities from same-level techs in other categories + for (const tech of this.techs) { + if ( + tech.level === clickedTech.level && + tech.category !== clickedTech.category && + priorities.has(tech.id) + ) { + this.eventBus.emit(new SendResearchTreeSelectIntentEvent(tech.id)); + } + } + } - // Clicking sets this as the current research priority (server handles distribution) this.eventBus.emit(new SendResearchTreeSelectIntentEvent(id)); this.requestUpdate(); } - private onActivateScorchedEarth(event: Event): void { - event.stopPropagation(); - event.preventDefault(); + private prioritizeCategory(category: Category) { if (!this.game || !this.eventBus) return; const me = this.game.myPlayer(); - if (!me || this.game.inSpawnPhase()) return; - if (me.hasUpgrade?.(UpgradeType.ScorchedEarth)) return; - this.eventBus.emit(new SendScorchedEarthIntentEvent()); - } - - private renderScorchedEarthAction( - tech: TechNode, - me: PlayerView | null, - isResearched: boolean, - ) { - // Show Scorched Earth button on Mechanized Warfare Doctrine (Land-2) when researched - if ( - tech.id !== RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE || - !me || - !isResearched - ) { - return ""; - } - const config = this.game?.config?.(); - if (!config) return ""; - const activationCost = config.scorchedEarthActivationCost(me); - const gold = me.gold(); - const hasUpgrade = me.hasUpgrade(UpgradeType.ScorchedEarth); - const disabled = - hasUpgrade || this.game?.inSpawnPhase?.() || gold < activationCost; - const tooltip = hasUpgrade - ? "Scorched Earth already active." - : gold < activationCost - ? "Earn more gold to activate Scorched Earth." - : "Activate to raze your road network."; - return html` - - `; - } - - private renderLegend() { - return html` -
- Required - Requires one of - Researched - Unmet requirements are grayed out -
- `; - } + if (!me) return; - private getOrderedTabs(): ResearchTab[] { - const available = new Set(this.categories); - const ordered = this.tabOrder.filter((cat) => { - if (cat === "Overview" || cat === "Policy Directives") return true; - return available.has(cat); - }); - if (!ordered.includes("Overview") && available.size > 0) - ordered.push("Overview"); - return ordered.length ? ordered : [...available]; - } + // First, clear all priorities from other categories + const allTechs = this.techs; + const researched = this.researchedIDsFromGame(); + const priorities = me.researchPriorities?.() ?? new Set(); - private getActiveCategory(): Category | null { - if (this.activeTab === "Overview") return null; - const tabs = this.getOrderedTabs(); - if (!tabs.length) return null; - return tabs.includes(this.activeTab) - ? (this.activeTab as Category) - : (tabs[0] as Category); - } + // Remove priorities from techs in other categories + for (const tech of allTechs) { + if (tech.category !== category && priorities.has(tech.id)) { + this.eventBus.emit(new SendResearchTreeSelectIntentEvent(tech.id)); + } + } - private onTabClick(cat: ResearchTab) { - if (cat === this.activeTab) return; - this.activeTab = cat; + // Now prioritize all techs in this category that aren't already prioritized + const categoryTechs = this.techs.filter((t) => t.category === category); - // Mark policy directives as seen when viewing the tab - if (cat === "Policy Directives" && this.eventBus) { - const me = this.game?.myPlayer?.(); - if (me?.hasUnseenPolicyDirectives?.()) { - this.eventBus.emit(new SendMarkPolicyDirectivesSeenIntentEvent()); + for (const tech of categoryTechs) { + if (!researched.has(tech.id) && !priorities.has(tech.id)) { + this.eventBus.emit(new SendResearchTreeSelectIntentEvent(tech.id)); } } + + // Force UI update after a short delay to show changes + setTimeout(() => this.requestUpdate(), 50); } private handleInvestmentSync = (event: Event) => { const { detail } = event as CustomEvent; if (!detail) return; - this.roadInvestmentRate = detail.road; this.researchInvestmentRate = detail.research; - this.lockRoad = detail.lockRoad; this.lockResearch = detail.lockResearch; - this.roadInvestmentEnabled = detail.roadEnabled; }; private requestInvestmentSync() { @@ -278,582 +194,67 @@ export class ResearchTreeModal extends LitElement { ); } - private handleInvestmentInput(slider: "road" | "research", event: Event) { - const input = event.target as HTMLInputElement; - const value = Math.max( - 0, - Math.min(1, (parseInt(input.value || "0", 10) || 0) / 100), - ); - const currentValue = - slider === "road" ? this.roadInvestmentRate : this.researchInvestmentRate; - const locked = slider === "road" ? this.lockRoad : this.lockResearch; - const enabled = slider === "road" ? this.canUseRoadSlider() : true; - if (locked || !enabled) { - input.value = Math.round(currentValue * 100).toString(); - return; - } - this.dispatchInvestmentRequest({ type: "set", slider, value }); - } - - private handleInvestmentToggle(slider: "road" | "research") { - if (slider === "road" && !this.canUseRoadSlider()) return; - this.dispatchInvestmentRequest({ type: "toggle-lock", slider }); - } - - private canUseRoadSlider(): boolean { - if (this.roadInvestmentEnabled) return true; - const me = this.game?.myPlayer?.(); - return me?.hasUpgrade?.(UpgradeType.Roads) ?? false; - } - - private renderRoadSlider(me: PlayerView | null) { - const hasRoads = this.canUseRoadSlider(); - const displayValue = hasRoads ? this.roadInvestmentRate : 0; - const percent = Math.round(displayValue * 100); - const quality = me?.roadNetworkQuality?.() ?? 100; - const completion = me?.roadNetworkCompletion?.() ?? 100; - const tooltipKey = hasRoads - ? this.lockRoad - ? "research_tree.investment.slider_locked" - : "research_tree.investment.slider_unlocked" - : "research_tree.investment.road_disabled"; - const tooltip = translateText(tooltipKey); - const breakEvenMarker = this.renderRoadBreakEvenMarker(me, hasRoads); - return html` -
- -
-
-
- ${breakEvenMarker} - this.handleInvestmentInput("road", e)} - @dblclick=${() => hasRoads && this.handleInvestmentToggle("road")} - /> -
-
${tooltip}
-
- `; - } - - private renderRoadBreakEvenMarker(me: PlayerView | null, enabled: boolean) { - if (!enabled || !me) return ""; - const config = this.game?.config?.(); - if (!config) return ""; - const pxPerSecond = me.roadNetPixelsPerSecond?.() ?? 0; - const base = config.roadConstructionBaseCost(); - const maintMult = config.roadMaintenanceMultiplier(); - const length = me.roadNetworkLength?.() ?? 0; - const quality = me.roadNetworkQuality?.() ?? 100; - const maintenancePerSecond = - (length * base * maintMult * Math.max(0.1, quality / 100)) / 60; - const grossPerSecond = pxPerSecond * base; - let breakEven = 0; - if (grossPerSecond > 0) breakEven = maintenancePerSecond / grossPerSecond; - else breakEven = maintenancePerSecond > 0 ? 1 : 0; - if (!Number.isFinite(breakEven)) breakEven = 0; - breakEven = Math.max(0, Math.min(1, breakEven)); - if (breakEven <= 0 || breakEven >= 1) return ""; - const leftPct = (breakEven * 100).toFixed(2); - return html`
`; - } - private renderResearchSlider() { const percent = Math.round(this.researchInvestmentRate * 100); - const lockTooltip = translateText( - this.lockResearch - ? "research_tree.investment.slider_locked" - : "research_tree.investment.slider_unlocked", - ); - const goalTooltip = translateText("research_tree.investment.research_goal"); - const tooltip = `${goalTooltip} ${lockTooltip}`; + return html` -
- -
-
-
+
+ Investment: ${percent}% +
this.handleInvestmentInput("research", e)} - @dblclick=${() => this.handleInvestmentToggle("research")} + class="research-slider" + @input=${(e: Event) => this.handleInvestmentInput(e)} />
-
${tooltip}
`; } - private computePositions(): { [id: string]: DOMRect } { - const map: { [id: string]: DOMRect } = {}; - const cards = this.renderRoot.querySelectorAll( - ".tech[data-id]", - ) as NodeListOf; - cards.forEach((el) => { - const id = el.dataset.id!; - map[id] = el.getBoundingClientRect(); - }); - return map; - } - - // Orchestrate layout updates and edge redraw - private updateLayout() { - requestAnimationFrame(() => this.drawEdges()); - } - - private adjustTooltipPosition(target: HTMLElement) { - const tooltip = target.querySelector(".tooltip") as HTMLElement | null; - const container = this.renderRoot.querySelector( - ".tree-container", - ) as HTMLElement | null; - if (!tooltip || !container) return; - - const containerRect = container.getBoundingClientRect(); - const rect = target.getBoundingClientRect(); - const tooltipRect = tooltip.getBoundingClientRect(); - const tooltipHeight = - tooltipRect.height || tooltip.offsetHeight || tooltip.scrollHeight || 0; - const tooltipWidth = - tooltipRect.width || tooltip.offsetWidth || tooltip.scrollWidth || 0; - const gap = 12; - const spaceAbove = rect.top - containerRect.top; - const spaceBelow = containerRect.bottom - rect.bottom; - - let position: "above" | "below" = "above"; - if (spaceAbove < tooltipHeight + gap && spaceBelow > spaceAbove) { - position = "below"; - } - - tooltip.dataset.position = position; - - // Clamp horizontal position so tooltips stay inside the container - const centerX = rect.left + rect.width / 2; - const margin = 20; - const availableWidth = Math.max(0, containerRect.width - margin * 2); - if (availableWidth <= 0) return; - const halfWidth = Math.min(tooltipWidth / 2, availableWidth / 2); - const minCenter = containerRect.left + margin + halfWidth; - const maxCenter = containerRect.right - margin - halfWidth; - const clampedCenter = Math.min(maxCenter, Math.max(minCenter, centerX)); - const shift = clampedCenter - centerX; - tooltip.style.setProperty("--tooltip-shift", `${shift}px`); - - // If still overflowing above, flip below as a final safeguard - if (position === "above" && spaceAbove < tooltipHeight + gap) { - tooltip.dataset.position = "below"; - } - } - - private renderAllView( - levels: number[], - researched: Set, - categoryColors: Record, - percentages: Map, - ) { - if (!this.categories.length) { - return html`
No research categories found.
`; - } - return html` -
- ${this.categories.map((cat) => { - const accent = - categoryColors[cat] ?? - "color-mix(in srgb, var(--ui-info) 6%, transparent)"; - return html`
-
${cat}
- ${levels.map((lvl) => { - const techs = this.techs.filter( - (t) => t.category === cat && t.level === lvl, - ); - return html`
-
L${lvl}
-
- ${techs.length - ? techs.map((tech) => { - const isResearched = researched.has(tech.id); - const pct = percentages.get(tech.id) ?? 0; - const display = getTechDisplay(tech); - return html`
- ${display.name} (${pct}%) - ${isResearched - ? html`โœ”` - : ""} -
`; - }) - : html`
โ€”
`} -
-
`; - })} -
`; - })} -
- `; - } - - private renderPolicyDirectivesView() { - const me = this.game?.myPlayer?.(); - if (!me) { - return html` -
-
Loading...
-
- `; - } - - // Get all policy directives, filter out those where a choice has already been made - const allDirectives = getAllPolicyDirectives(); - const pendingDirectives = allDirectives.filter( - (d) => me.getPolicyChoice?.(d.id) === null, - ); - const unlockedDirectives = getUnlockedDirectives((techId) => - me.hasResearchedTech(techId), - ); - - // Sort so unlocked (available) directives appear first - const sortedDirectives = [...pendingDirectives].sort((a, b) => { - const aUnlocked = unlockedDirectives.some((d) => d.id === a.id); - const bUnlocked = unlockedDirectives.some((d) => d.id === b.id); - if (aUnlocked && !bUnlocked) return -1; - if (!aUnlocked && bUnlocked) return 1; - return 0; - }); - - if (pendingDirectives.length === 0) { - return html` -
-
- No pending policy directives. New directives will appear here when - you research certain technologies. -
-
- `; - } - - return html` -
-
-

- Policy Directives become available when you research certain - technologies. Choose a policy to receive additional bonuses. -

-
-
- ${sortedDirectives.map((directive) => { - const isUnlocked = unlockedDirectives.some( - (d) => d.id === directive.id, - ); - const currentChoice = me.getPolicyChoice?.(directive.id) ?? null; - return this.renderPolicyDirective( - directive, - isUnlocked, - currentChoice, - ); - })} -
-
- `; - } - - private renderPolicyDirective( - directive: PolicyDirective, - isUnlocked: boolean, - currentChoice: string | null, - ) { - return html` -
-
-

${directive.name}

- ${!isUnlocked - ? html` - ๐Ÿ”’ Requires: - ${getTechMeta(directive.unlockedByTech, { strict: false }) - ?.name ?? directive.unlockedByTech} - ` - : ""} -
-

${directive.description}

-
- ${directive.options.map((option) => { - const isSelected = currentChoice === option.id; - // Disable if not unlocked OR if a choice has already been made (one-time selection) - const hasChoiceMade = currentChoice !== null; - const isDisabled = !isUnlocked || hasChoiceMade; - return html` - - `; - })} -
-
- `; - } - - private onPolicyOptionClick(directiveId: string, optionId: string) { - if (!this.game || !this.eventBus) return; - const me = this.game.myPlayer?.(); - if (!me) return; - - // Don't allow selection if any choice has already been made (one-time selection) - const currentChoice = me.getPolicyChoice?.(directiveId); - if (currentChoice !== null && currentChoice !== undefined) return; - - // Emit the intent to select this policy - this.eventBus.emit( - new SendPolicyDirectiveSelectIntentEvent(directiveId, optionId), - ); - this.requestUpdate(); - } - - private drawEdges() { - const container = this.renderRoot.querySelector( - ".line-layer", - ) as HTMLElement | null; - if (!container) return; - const svg = container.querySelector("svg"); - if (!svg) return; - while (svg.firstChild) svg.removeChild(svg.firstChild); - - if (this.activeTab === "Overview") return; - const activeCategory = this.getActiveCategory(); - if (!activeCategory) return; - - const visibleTechs = this.techs.filter( - (t) => t.category === activeCategory, + private handleInvestmentInput(event: Event) { + const input = event.target as HTMLInputElement; + const value = Math.max( + 0, + Math.min(1, (parseInt(input.value || "0", 10) || 0) / 100), ); - if (!visibleTechs.length) return; - - const pos = this.computePositions(); - const treeEl = this.renderRoot.querySelector( - ".tree-container", - ) as HTMLElement | null; - if (!treeEl) return; - const rootRect = treeEl.getBoundingClientRect(); - const scrollLeft = treeEl.scrollLeft; - const scrollTop = treeEl.scrollTop; - - const me = this.game?.myPlayer?.(); - const researched = this.researchedIDsFromGame(); - const priority = me?.researchPriorityTech?.() ?? null; - - const byId = new Map(visibleTechs.map((n) => [n.id, n] as const)); - const buildMissingPrereqPath = (targetId: string): Set => { - const path = new Set(); - const seen = new Set(); - const dfs = (tid: string) => { - if (seen.has(tid)) return; - seen.add(tid); - const node = byId.get(tid); - if (!node) return; - const reqAll = (node.requiresAllOf ?? []).filter((p) => byId.has(p)); - const reqOne = (node.requiresOneOf ?? []).filter((p) => byId.has(p)); - for (const r of reqAll) { - if (!researched.has(r)) { - path.add(r); - dfs(r); - } - } - if (reqOne.length > 0 && !reqOne.some((p) => researched.has(p))) { - const sorted = [...reqOne].sort( - (a, b) => (byId.get(a)?.level ?? 0) - (byId.get(b)?.level ?? 0), - ); - const choice = sorted[0]; - if (choice && !researched.has(choice)) { - path.add(choice); - dfs(choice); - } - } - }; - if (targetId && byId.has(targetId)) dfs(targetId); - return path; - }; - - const highlightNodes = new Set(); - if (priority && byId.has(priority)) { - highlightNodes.add(priority); - const missing = buildMissingPrereqPath(priority); - for (const id of missing) highlightNodes.add(id); - } - - const addLine = (fromId: string, toId: string, cls: string) => { - const a = pos[fromId]; - const b = pos[toId]; - if (!a || !b) return; - const x1 = a.right - rootRect.left + scrollLeft; - const y1 = a.top - rootRect.top + scrollTop + a.height / 2; - const x2 = b.left - rootRect.left + scrollLeft; - const y2 = b.top - rootRect.top + scrollTop + b.height / 2; - const midX = (x1 + x2) / 2; - const path = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - const d = `M ${x1},${y1} L ${midX},${y1} L ${midX},${y2} L ${x2},${y2}`; - path.setAttribute("d", d); - path.setAttribute("fill", "none"); - const isHighlighted = - highlightNodes.has(fromId) && highlightNodes.has(toId); - path.setAttribute( - "class", - `edge ${cls} ${isHighlighted ? "highlight" : ""}`, - ); - svg.appendChild(path); - }; - - for (const t of visibleTechs) { - const reqAll = (t.requiresAllOf ?? []).filter((id) => byId.has(id)); - const reqOne = (t.requiresOneOf ?? []).filter((id) => byId.has(id)); - - for (const p of reqAll) addLine(p, t.id, "req"); - for (const p of reqOne) addLine(p, t.id, "oneof"); + const locked = this.lockResearch; + if (locked) { + input.value = Math.round(this.researchInvestmentRate * 100).toString(); + return; } + this.dispatchInvestmentRequest({ type: "set", slider: "research", value }); } protected firstUpdated(_changed: PropertyValues): void { super.firstUpdated(_changed); - setTimeout(() => this.updateLayout(), 0); - window.addEventListener("resize", this.handleResize); - // Watch scroll on the whole tree container (both axes) - const tree = this.renderRoot.querySelector(".tree-container"); - tree?.addEventListener( - "scroll", - this.handleResize as any, - { - passive: true, - } as any, - ); this.requestInvestmentSync(); } disconnectedCallback(): void { super.disconnectedCallback(); - window.removeEventListener("resize", this.handleResize); - // content no longer scrolls for this modal; listener removed - const tree = this.renderRoot.querySelector(".tree-container"); - tree?.removeEventListener("scroll", this.handleResize as any); window.removeEventListener( INVESTMENT_SYNC_EVENT, this.handleInvestmentSync as EventListener, ); } - private handleResize = () => { - this.updateLayout(); - }; - - // No per-match listener needed; UI reflects game state directly - - protected updated(): void { - // Schedule a layout update on the next animation frame - requestAnimationFrame(() => this.updateLayout()); - } - render() { - const levels = Array.from(new Set(this.techs.map((t) => t.level))).sort( - (a, b) => a - b, - ); const researched = this.researchedIDsFromGame(); const categoryColors: Record = { - Land: "color-mix(in srgb, var(--ui-info) 12%, transparent)", - Sea: "color-mix(in srgb, var(--ui-info) 10%, transparent)", - Air: "color-mix(in srgb, var(--ui-secondary) 12%, transparent)", - Nuclear: "color-mix(in srgb, var(--ui-alert) 12%, transparent)", - Economy: "color-mix(in srgb, var(--ui-success) 12%, transparent)", + Land: "#2ecc71", + Sea: "#3498db", + Air: "#9b59b6", + Nuclear: "#e74c3c", }; const me = this.game?.myPlayer?.(); - const priority = me?.researchPriorityTech?.() ?? null; - const tabs = this.getOrderedTabs(); - const isAllView = this.activeTab === "Overview"; - const isPolicyDirectivesView = this.activeTab === "Policy Directives"; - const activeCategory = this.getActiveCategory(); - const activeTechs = activeCategory - ? this.techs.filter((t) => t.category === activeCategory) - : []; - const activeMap = new Map(activeTechs.map((n) => [n.id, n] as const)); + const priorities = me?.researchPriorities?.() ?? new Set(); + const percentByTechId = (() => { const map = new Map(); for (const tech of this.techs) { @@ -867,1212 +268,464 @@ export class ResearchTreeModal extends LitElement { } return map; })(); - const highlightTrail = (() => { - const set = new Set(); - if (!priority || !activeCategory || !activeMap.has(priority)) return set; - const seen = new Set(); - const dfs = (tid: string) => { - if (seen.has(tid)) return; - seen.add(tid); - const node = activeMap.get(tid); - if (!node) return; - const reqAll = (node.requiresAllOf ?? []).filter((p) => - activeMap.has(p), - ); - const reqOne = (node.requiresOneOf ?? []).filter((p) => - activeMap.has(p), - ); - for (const r of reqAll) { - if (!researched.has(r)) { - set.add(r); - dfs(r); - } - } - if (reqOne.length > 0 && !reqOne.some((p) => researched.has(p))) { - const sorted = [...reqOne].sort( - (a, b) => - (activeMap.get(a)?.level ?? 0) - (activeMap.get(b)?.level ?? 0), - ); - const choice = sorted[0]; - if (choice && !researched.has(choice)) { - set.add(choice); - dfs(choice); - } - } - }; - set.add(priority); - dfs(priority); - return set; - })(); + + // Group techs by category for the grid layout + const techsByCategory = new Map(); + for (const cat of this.categories) { + techsByCategory.set( + cat, + this.techs.filter((t) => t.category === cat), + ); + } return html` - ${this.renderLegend()} -
-
-
- ${tabs.map((cat) => { - const isAllTab = cat === "Overview"; - const isPolicyTab = cat === "Policy Directives"; - const isActive = isAllTab - ? isAllView - : isPolicyTab - ? isPolicyDirectivesView - : cat === activeCategory; - const tabTooltip = translateText( - `research_tree.tab_tooltip.${cat.toLowerCase().replace(" ", "_")}`, - ); - const showPolicyBadge = - isPolicyTab && me?.hasUnseenPolicyDirectives?.(); - return html``; - })} -
-
${this.renderResearchSlider()}
+ +
+
+
Research
+ ${this.renderResearchSlider()}
-
-
- ${isPolicyDirectivesView - ? this.renderPolicyDirectivesView() - : isAllView - ? this.renderAllView( - levels, - researched, - categoryColors, - percentByTechId, - ) - : activeCategory - ? html`
+ ${this.categories.map((cat) => { + const techs = techsByCategory.get(cat) ?? []; + const catClass = `cat-${cat.toLowerCase()}`; + + const icons: Record = { + Land: landIcon, + Sea: seaIcon, + Air: airIcon, + Nuclear: nuclearIcon, + }; + const iconSrc = icons[cat]; + + // Check if all non-researched techs in category are prioritized + const nonResearchedTechs = techs.filter( + (t) => !researched.has(t.id), + ); + const allPrioritized = + nonResearchedTechs.length > 0 && + nonResearchedTechs.every((t) => priorities.has(t.id)); + + return html` +
+
+ ${iconSrc + ? html`
` + : ""} + ${cat.toUpperCase()} + +
+ ${techs.map((tech) => { + const available = this.isAvailable(tech.id, researched); + const isResearched = researched.has(tech.id); + const pct = percentByTechId.get(tech.id) ?? 0; + const isPriority = priorities.has(tech.id); + const display = getTechDisplay(tech); + const tooltip = getDetailedTechTooltip(tech.id); + + const rowClass = [ + "tech-row", + isResearched ? "researched" : "", + !available && !isResearched ? "locked" : "", + ] + .filter(Boolean) + .join(" "); + + const barClass = `bar-${cat.toLowerCase()}`; + + // Locked techs show as prioritized (yellow) if they're set as priority + const btnClass = [ + "priority-btn", + isPriority && available + ? "active" + : isPriority && !available + ? "locked-prioritized" + : "", + ] + .filter(Boolean) + .join(" "); + + return html` +
this.onTechClick(tech.id)} + title=${tooltip} > - ${levels - .filter((lvl) => - this.techs.some( - (t) => - t.level === lvl && - t.category === activeCategory, - ), - ) - .map((lvl) => { - const techsForLevel = this.techs.filter( - (t) => - t.level === lvl && - t.category === activeCategory, - ); - return html`
-
Tech Level ${lvl}
-
- ${techsForLevel.map((tech) => { - const available = this.isAvailable( - tech.id, - researched, - ); - const isResearched = researched.has(tech.id); - const clickable = !isResearched; - const inHighlight = highlightTrail.has( - tech.id, - ); - const classes = [ - "tech", - available ? "" : "locked", - isResearched ? "researched" : "", - inHighlight ? "priority" : "", - ] - .filter(Boolean) - .join(" "); - const action = this.renderScorchedEarthAction( - tech, - me ?? null, - isResearched, - ); - return html`
- ${(() => { - const display = getTechDisplay(tech); - return html``; - })()} - ${action} -
`; - })} -
-
`; - })} -
` - : html`
- No research categories found. -
`} - ${!isAllView && !isPolicyDirectivesView - ? html`
` - : ""} -
+
+
${display.name}
+
+ ${display.shortDescription ?? ""} +
+
+
+
+ ${pct > 0 && pct < 100 + ? html`${pct}%` + : ""} +
+ +
+ `; + })} +
+ `; + })}
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index dc409f295..537921841 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -943,6 +943,7 @@ export class SinglePlayerModal extends LitElement { peaceTimerDurationMinutes: this.selectedPeaceTimerDuration, startingGold: this.startingGold, goldMultiplier: this.goldMultiplier, + chatEnabled: false, }, }, } satisfies JoinLobbyEvent, diff --git a/src/client/StatisticsModal.ts b/src/client/StatisticsModal.ts index b156e43db..b45c97ee1 100644 --- a/src/client/StatisticsModal.ts +++ b/src/client/StatisticsModal.ts @@ -250,13 +250,7 @@ export class StatisticsModal extends LitElement { ] : []; - const categories: Category[] = [ - "Land", - "Sea", - "Air", - "Nuclear", - "Economy", - ]; + const categories: Category[] = ["Land", "Sea", "Air", "Nuclear"]; const nodes = getTechNodes(); const techsByCategory: Array<[string, string]> = sel ? categories.map((cat) => { diff --git a/src/client/TechTooltips.ts b/src/client/TechTooltips.ts new file mode 100644 index 000000000..c7ee8c104 --- /dev/null +++ b/src/client/TechTooltips.ts @@ -0,0 +1,79 @@ +import { + ARTILLERY_UPGRADES, + BOMBER_UPGRADES, + FIGHTER_UPGRADES, + SUBMARINE_UPGRADES, + WARSHIP_UPGRADES, +} from "../core/game/UnitUpgrades"; +import { RESEARCH_TECH_IDS } from "../core/tech/TechIds"; + +export function getDetailedTechTooltip(techId: string): string { + switch (techId) { + // --- SEA --- + case RESEARCH_TECH_IDS.SEA_MISSILE_NAVY: { + const w2 = WARSHIP_UPGRADES[1]; + const s1 = SUBMARINE_UPGRADES[0]; + return `Unlocks:\nโ€ข Gen 2 Warships (+25% health to ${w2.maxHealth}, +35% min damage to ${w2.damageMin}, +21.5% max damage to ${w2.damageMax})\nโ€ข Gen 1 Submarines (${s1.maxHealth} health, ${s1.damageMin}-${s1.damageMax} damage, stealth)`; + } + case RESEARCH_TECH_IDS.SEA_ADVANCED_FLEET: { + const w3 = WARSHIP_UPGRADES[2]; + const s2 = SUBMARINE_UPGRADES[1]; + return `Unlocks:\nโ€ข Gen 3 Warships (+20% health to ${w3.maxHealth}, +25.9% min damage to ${w3.damageMin}, +17.7% max damage to ${w3.damageMax})\nโ€ข Gen 2 Submarines (+25% health to ${s2.maxHealth}, +35% min damage to ${s2.damageMin}, +21.5% max damage to ${s2.damageMax})`; + } + case RESEARCH_TECH_IDS.SEA_NUCLEAR_SUBMARINES: { + const s3 = SUBMARINE_UPGRADES[2]; + return `Unlocks:\nโ€ข Gen 3 Submarines (+20% health to ${s3.maxHealth}, +25.9% min damage to ${s3.damageMin}, +17.7% max damage to ${s3.damageMax})\nโ€ข Ship Anti-Air: Warships engage and destroy aircraft within range`; + } + case RESEARCH_TECH_IDS.SEA_TBD_LEVEL4: + return `Unlocks:\nโ€ข Nuclear Subs: Enables submarines to launch nuclear weapons while submerged and undetected (second-strike capability)`; + + // --- LAND --- + case RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS: + return `Unlocks:\nโ€ข Roads: Increases unit movement speed, generates passive trade income per connected tile\nโ€ข Trade Routes: Trade ships establish international commerce routes for continuous gold income`; + case RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY: { + const a1 = ARTILLERY_UPGRADES[0]; + return `Unlocks:\nโ€ข City Anti-Air: Cities automatically engage enemy aircraft with AA batteries\nโ€ข Improved SAM: +35% range to 94.5 pixels, improved accuracy vs bombers/fighters/missiles\nโ€ข Artillery Level 1 (${a1.maxHealth} health, ${a1.damageMin}-${a1.damageMax} damage, 60 tile range): Land-based heavy artillery spawns from Factories`; + } + case RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS: { + const a2 = ARTILLERY_UPGRADES[1]; + return `Unlocks:\nโ€ข Advanced SAM: +82.25% range to 127.6 pixels (exceeds H-bomb radius), max interception success\nโ€ข Hospitals: Increases city population growth rate (faster troop production & economy)\nโ€ข Artillery Level 2 (+20% health to ${a2.maxHealth}, damage ${a2.damageMin}-${a2.damageMax}, 75 tile range)`; + } + case RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE: { + const a3 = ARTILLERY_UPGRADES[2]; + return `Unlocks:\nโ€ข Military Academy: Unlocks Academy structure; each connected Academy increases enemy troop casualties you inflict in land battles (+10% with one, ~+15% with two, up to +20% cap; applies on attack and defense)\nโ€ข Artillery Level 3 (+16.7% health to ${a3.maxHealth}, damage ${a3.damageMin}-${a3.damageMax}, 90 tile range)`; + } + + // --- AIR --- + case RESEARCH_TECH_IDS.AIR_PARATROOPERS: { + const f1 = FIGHTER_UPGRADES[0]; + return `Unlocks:\nโ€ข Gen 1 Fighters (${f1.maxHealth} health, ${f1.damageMin}-${f1.damageMax} damage, engages aircraft)\nโ€ข Paratroopers: Airborne infantry deployed behind enemy lines for rapid expansion`; + } + case RESEARCH_TECH_IDS.AIR_ADVANCED_JETS: { + const f2 = FIGHTER_UPGRADES[1]; + const b2 = BOMBER_UPGRADES[1]; + return `Unlocks:\nโ€ข Gen 2 Fighters (+33.3% health to ${f2.maxHealth}, +50% min damage to ${f2.damageMin}, +30.8% max damage to ${f2.damageMax})\nโ€ข Heavy Bombers (+20% health to ${b2.maxHealth}, +20% damage to ${b2.damageMin}, +40% range to ${b2.targetRange}, +50% speed to 3)`; + } + case RESEARCH_TECH_IDS.AIR_NAVAL_STRIKE: { + const f3 = FIGHTER_UPGRADES[2]; + return `Unlocks:\nโ€ข Gen 3 Fighters (+25% health to ${f3.maxHealth}, +33.3% min damage to ${f3.damageMin}, +23.5% max damage to ${f3.damageMax})\nโ€ข Naval Strike: Fighters can attack warships, transport ships, and trade ships`; + } + case RESEARCH_TECH_IDS.AIR_TBD_LEVEL4: { + const f4 = FIGHTER_UPGRADES[3]; + const b3 = BOMBER_UPGRADES[2]; + return `Unlocks:\nโ€ข Gen 4 Fighters (+20% health to ${f4.maxHealth}, +25% min damage to ${f4.damageMin}, +19% max damage to ${f4.damageMax})\nโ€ข Supersonic Bombers (+16.7% health to ${b3.maxHealth}, +16.7% damage to ${b3.damageMin}, +28.6% range to ${b3.targetRange}, +33.3% speed to 4)`; + } + + // --- NUCLEAR --- + case RESEARCH_TECH_IDS.NUCLEAR_FISSION: + return `Unlocks:\nโ€ข Atom Bomb: Basic fission weapon with large blast radius (inner: 12px, outer: 30px)\nโ€ข Missile Silo: Required launch facility for deploying nuclear weapons`; + case RESEARCH_TECH_IDS.THERMONUCLEAR_STAGING: + return `Unlocks:\nโ€ข Hydrogen Bomb: High-yield fusion weapon with massive blast radius (inner: 80px, outer: 100px) - devastates multi-tile areas`; + case RESEARCH_TECH_IDS.MIRV_TECHNOLOGY: + return `Unlocks:\nโ€ข MIRV: Multiple Independent Reentry Vehicles - deploys multiple warheads per missile, significantly harder for SAMs to intercept (50% hit chance vs 100% for atom bombs)`; + case RESEARCH_TECH_IDS.NUCLEAR_TBD_LEVEL4: + return `Unlocks:\nโ€ข Doomsday Device: Auto-triggers when any of your tiles are hit by a nuke; consumes the device and unleashes a global fallout wave that instantly deletes bombers/fighters/warships/trade ships, damages other structures by 80% of current health, relinquishes land, and spreads fallout across the world`; + + default: + return "No detailed information available."; + } +} diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 009fcd9e3..e7ce1a71c 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -11,11 +11,6 @@ import { } from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { PlayerView } from "../core/game/GameView"; -import { - isUpgradeableUnit, - maxStructureLevel, - maxUnitLevel, -} from "../core/game/Upgradeables"; import { AllPlayersStats, ClientHashMessage, @@ -124,21 +119,10 @@ export class BuildUnitIntentEvent implements GameEvent { ) {} } -export class SendScorchedEarthIntentEvent implements GameEvent {} - export class SendResearchTreeSelectIntentEvent implements GameEvent { constructor(public readonly techId: string) {} } -export class SendPolicyDirectiveSelectIntentEvent implements GameEvent { - constructor( - public readonly directiveId: string, - public readonly optionId: string, - ) {} -} - -export class SendMarkPolicyDirectivesSeenIntentEvent implements GameEvent {} - export class SendTargetPlayerIntentEvent implements GameEvent { constructor(public readonly targetID: PlayerID) {} } @@ -245,6 +229,20 @@ export class MoveFighterJetIntentEvent implements GameEvent { ) {} } +export class MoveArtilleryIntentEvent implements GameEvent { + constructor( + public readonly unitId: number, + public readonly tile: TileRef, + ) {} +} + +export class ArtilleryOutOfRangeEvent implements GameEvent { + constructor( + public readonly level: number, + public readonly maxDistance: number, + ) {} +} + export class SendBomberIntentEvent implements GameEvent { constructor( public readonly targetID: PlayerID | null, // who to attack @@ -351,9 +349,6 @@ export class Transport { this.onSendSetAutoBombingEvent(e), ); - this.eventBus.on(SendScorchedEarthIntentEvent, () => - this.onSendScorchedEarthIntent(), - ); this.eventBus.on(SendUpgradeStructureIntentEvent, (e) => this.onSendUpgradeStructureIntent(e), ); @@ -368,14 +363,6 @@ export class Transport { this.onSendResearchTreeSelectIntent(e), ); - this.eventBus.on(SendPolicyDirectiveSelectIntentEvent, (e) => - this.onSendPolicyDirectiveSelectIntent(e), - ); - - this.eventBus.on(SendMarkPolicyDirectivesSeenIntentEvent, () => - this.onSendMarkPolicyDirectivesSeenIntent(), - ); - this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e)); this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e)); @@ -400,6 +387,9 @@ export class Transport { this.eventBus.on(MoveFighterJetIntentEvent, (e) => { this.onMoveFighterJetEvent(e); }); + this.eventBus.on(MoveArtilleryIntentEvent, (e) => { + this.onMoveArtilleryEvent(e); + }); this.eventBus.on(SendKickPlayerIntentEvent, (e) => this.onSendKickPlayerIntent(e), ); @@ -767,65 +757,36 @@ export class Transport { this._lastBuildUnit = event.unit; this._lastBuildAt = now; - // Compute desired starting level for upgradeable structures or units from local settings. - let targetLevel: number | undefined; + // Read stack count from localStorage (in-game communication) + let stackCount: number | undefined; let bomberLevel: number | undefined; try { - const key = String(event.unit); - if (isUpgradeableUnit(event.unit)) { - const rawUnits = localStorage.getItem("unitUpgradeSettings.levels"); - if (rawUnits) { - const obj = JSON.parse(rawUnits) as Record; - const val = obj?.[key]; - if (typeof val === "number" && val > 1) { - // Server will clamp to player's researched max level - targetLevel = Math.min(maxUnitLevel(event.unit), val); - } - } - } else { - const rawStruct = localStorage.getItem("buildSettings.levels"); - if (rawStruct) { - const obj = JSON.parse(rawStruct) as Record; - const val = obj?.[key]; - if (typeof val === "number" && val > 1) { - targetLevel = Math.min(maxStructureLevel(event.unit), val); - } - } - } - // For airfields, also get bomber upgrade level from unit upgrade settings - if (event.unit === UnitType.Airfield) { - const rawUnits = localStorage.getItem("unitUpgradeSettings.levels"); - if (rawUnits) { - const obj = JSON.parse(rawUnits) as Record; - const val = obj?.[String(UnitType.Bomber)]; - if (typeof val === "number" && val > 1) { - bomberLevel = Math.min(maxUnitLevel(UnitType.Bomber), val); - } + const rawStack = localStorage.getItem("buildSettings.stackCount"); + if (rawStack) { + const obj = JSON.parse(rawStack) as Record; + const val = obj?.[String(event.unit)]; + if (typeof val === "number" && val > 1) { + stackCount = Math.min(25, val); } } } catch { - // Ignore malformed local storage. - targetLevel = undefined; + stackCount = undefined; bomberLevel = undefined; } + console.log( + `[Transport] Sending build_unit intent for ${event.unit} at tile ${event.tile}, stackCount=${stackCount}`, + ); this.sendIntent({ type: "build_unit", clientID: this.lobbyConfig.clientID, unit: event.unit, tile: event.tile, - targetLevel, + targetLevel: stackCount, // Renamed semantically but keeping wire format for now bomberLevel, }); } - private onSendScorchedEarthIntent() { - this.sendIntent({ - type: "activate_scorched_earth", - clientID: this.lobbyConfig.clientID, - }); - } - private onSendUpgradeStructureIntent(event: SendUpgradeStructureIntentEvent) { // Prefer new generic intent this.sendIntent({ @@ -854,24 +815,6 @@ export class Transport { }); } - private onSendPolicyDirectiveSelectIntent( - event: SendPolicyDirectiveSelectIntentEvent, - ) { - this.sendIntent({ - type: "policy_directive_select", - clientID: this.lobbyConfig.clientID, - directiveId: event.directiveId, - optionId: event.optionId, - }); - } - - private onSendMarkPolicyDirectivesSeenIntent() { - this.sendIntent({ - type: "mark_policy_directives_seen", - clientID: this.lobbyConfig.clientID, - }); - } - private onPauseGameEvent(event: PauseGameEvent) { if (!this.isLocal) { console.log(`cannot pause multiplayer games`); @@ -966,6 +909,16 @@ export class Transport { tile: event.tile, }); } + + private onMoveArtilleryEvent(event: MoveArtilleryIntentEvent) { + this.sendIntent({ + type: "move_artillery", + clientID: this.lobbyConfig.clientID, + unitId: event.unitId, + tile: event.tile, + }); + } + private onSendBomberIntent(event: SendBomberIntentEvent) { this.sendIntent({ type: "bomber_intent", diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index bf192093d..556fbea64 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -661,6 +661,15 @@ export class UserSettingModal extends LitElement { @change=${this.handleKeybindChange} > + +
${translateText("user_setting.nukes")}
diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 890d1927f..8f5a96114 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -168,7 +168,6 @@ export function getMessageTypeClasses(type: MessageType): string { case MessageType.TRADE_SHIP_CAPTURED_ENEMY: case MessageType.RECEIVED_GOLD_FROM_TRADE: case MessageType.CONQUERED_PLAYER: - case MessageType.INSURANCE_REFUND: return severityColors["success"]; case MessageType.ATTACK_FAILED: case MessageType.ALLIANCE_REJECTED: diff --git a/src/client/components/LobbyChatPanel.ts b/src/client/components/LobbyChatPanel.ts new file mode 100644 index 000000000..7c496acfd --- /dev/null +++ b/src/client/components/LobbyChatPanel.ts @@ -0,0 +1,174 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { LobbyMessage } from "../../core/Schemas"; +import { getServerConfigFromClient } from "../../core/configuration/ConfigLoader"; + +@customElement("lobby-chat-panel") +export class LobbyChatPanel extends LitElement { + @property({ type: Array }) messages: LobbyMessage[] = []; + @property({ type: String }) clientID: string = ""; + @property({ type: String }) gameID: string = ""; + @property({ type: String }) username: string = ""; + @state() private inputText: string = ""; + + updated(changedProperties: Map) { + super.updated(changedProperties); + if (changedProperties.has("messages")) { + this.scrollToBottom(); + } + } + + private async scrollToBottom() { + await this.updateComplete; + const container = this.renderRoot.querySelector( + ".lcp-messages", + ) as HTMLElement | null; + if (container) container.scrollTop = container.scrollHeight; + } + + private async sendMessage() { + const text = this.inputText.trim(); + console.log("LobbyChatPanel.sendMessage called", { + text, + clientID: this.clientID, + gameID: this.gameID, + username: this.username, + }); + if (!text) return; + if (!this.clientID || !this.gameID || !this.username) { + console.warn("LobbyChatPanel: Missing clientID, gameID, or username", { + clientID: this.clientID, + gameID: this.gameID, + username: this.username, + }); + return; + } + + const capped = text.slice(0, 300); + this.inputText = ""; // Clear immediately for better UX + + try { + const config = await getServerConfigFromClient(); + const url = `/${config.workerPath(this.gameID)}/api/lobby/${this.gameID}/messages`; + console.log("LobbyChatPanel: Sending POST to", url); + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientID: this.clientID, + username: this.username, + text: capped, + }), + }); + console.log("LobbyChatPanel: Response status", response.status); + if (!response.ok) { + console.error("Failed to send lobby message:", response.statusText); + } + } catch (err) { + console.error("Error sending lobby message:", err); + } + } + + render() { + return html` +
+
+ ${this.messages.map((m) => { + const displayName = m.isHost ? `${m.username} (Host)` : m.username; + const isLocal = m.clientID === this.clientID; + const msgClass = isLocal + ? "lcp-msg lcp-msg--local" + : "lcp-msg lcp-msg--remote"; + return html`
+ ${displayName}: ${m.text} +
`; + })} +
+
+ + (this.inputText = (e.target as HTMLInputElement).value)} + @keydown=${(e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + this.sendMessage(); + } + }} + placeholder="Type a message..." + /> + +
+
+ `; + } + + createRenderRoot() { + return this; // use light DOM for existing styles + } +} + +const style = document.createElement("style"); +style.textContent = ` + .lcp-container { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; + min-height: 200px; + } + .lcp-messages { + overflow-y: auto; + border: 1px solid #444; + border-radius: 8px; + padding: 8px; + flex: 1; + min-height: 100px; + background: rgba(0, 0, 0, 0.5); + color: #ddd; + display: flex; + flex-direction: column; + gap: 6px; + } + .lcp-msg { + font-size: 0.9rem; + padding: 6px 10px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.6); + } + .lcp-msg--local { + align-self: flex-end; + text-align: right; + background: rgba(36, 59, 85, 0.7); + } + .lcp-msg--remote { + align-self: flex-start; + text-align: left; + background: rgba(0, 0, 0, 0.6); + } + .lcp-sender { + color: #9ae6b4; + margin-right: 4px; + } + .lcp-input-row { + display: flex; + gap: 8px; + } + .lcp-input { + flex: 1; + border-radius: 8px; + padding: 6px 10px; + color: #000; + } + .lcp-send { + border-radius: 8px; + padding: 6px 12px; + } +`; +document.head.appendChild(style); diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 44d7f99be..63885df2a 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -6,6 +6,7 @@ import { PerformanceMetrics } from "../utilities/PerformanceMetrics"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; import { AABulletLayer } from "./layers/AABulletLayer"; +import { ArtilleryLayer } from "./layers/ArtilleryLayer"; import { AttackWarningOverlay } from "./layers/AttackWarningOverlay"; import { BuildMenu } from "./layers/BuildMenu"; import { CargoTruckLayer } from "./layers/CargoTruckLayer"; @@ -265,6 +266,7 @@ export function createRenderer( // World-space ring overlay for Defense Posts/SAMs new RangeOverlayLayer(game, eventBus, transformHandler, uiState), structureLayer, + new ArtilleryLayer(game, eventBus, transformHandler), new UnitLayer(game, eventBus, transformHandler, uiState), new AABulletLayer(game, transformHandler), new FxLayer(game, transformHandler), diff --git a/src/client/graphics/SpriteLoader.ts b/src/client/graphics/SpriteLoader.ts index 5c26ff80c..3aae45652 100644 --- a/src/client/graphics/SpriteLoader.ts +++ b/src/client/graphics/SpriteLoader.ts @@ -1,4 +1,5 @@ import { Colord } from "colord"; +import artillerySprite from "../../../proprietary/images/artillery-battery.png"; import atomBombSprite from "../../../resources/sprites/atombomb.png"; import bomberSprite from "../../../resources/sprites/bomber.png"; import cargoPlaneSprite from "../../../resources/sprites/cargoplane.png"; @@ -19,6 +20,7 @@ const SPRITE_CONFIG: Partial> = { [UnitType.TransportShip]: transportShipSprite, [UnitType.Warship]: warshipSprite, [UnitType.Submarine]: submarineSprite, + [UnitType.Artillery]: artillerySprite, [UnitType.SAMMissile]: samMissileSprite, [UnitType.AtomBomb]: atomBombSprite, [UnitType.HydrogenBomb]: hydrogenBombSprite, diff --git a/src/client/graphics/layers/ArtilleryLayer.ts b/src/client/graphics/layers/ArtilleryLayer.ts new file mode 100644 index 000000000..e2c3910d4 --- /dev/null +++ b/src/client/graphics/layers/ArtilleryLayer.ts @@ -0,0 +1,441 @@ +import * as PIXI from "pixi.js"; +import artilleryIcon from "../../../../proprietary/images/artillery-battery.png"; +import { Theme } from "../../../core/configuration/Config"; +import { EventBus } from "../../../core/EventBus"; +import { Cell, UnitType } from "../../../core/game/Game"; +import { GameUpdateType } from "../../../core/game/GameUpdates"; +import { GameView, UnitView } from "../../../core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "./Layer"; + +// Render textures at higher pixel density to stay crisp when scaled +const ICON_TEXTURE_QUALITY = 4; +const ICON_DIM = 28; +const ICON_GROW_ZOOM_THRESHOLD = 2; +// Artillery is 20% smaller than structures +const SIZE_SCALE = 0.8; + +class ArtilleryRenderInfo { + public healthBarGraphics: PIXI.Graphics | null = null; + + constructor( + public unit: UnitView, + public pixiSprite: PIXI.Sprite, + ) {} +} + +// Track when artillery last fired to show red flash for multiple ticks +const FIRING_FLASH_DURATION = 30; // ticks + +// Health bar colors and dimensions +const HEALTH_BAR_COLORS = [0xe81919, 0xf07a19, 0xcae70f, 0x2cef12]; // red, orange, yellow, green + +export class ArtilleryLayer implements Layer { + layerName = "ArtilleryLayer"; + private pixiCanvas: HTMLCanvasElement; + private stage: PIXI.Container; + private renderer: PIXI.Renderer; + private theme: Theme; + private renders: ArtilleryRenderInfo[] = []; + private seenUnits: Set = new Set(); + private textureCache: Map = new Map(); + private firingTextureCache: Map = new Map(); + private artilleryIconImage: HTMLImageElement | null = null; + private lastFiredTick: Map = new Map(); // unitId -> tick + + constructor( + private game: GameView, + private eventBus: EventBus, + private transformHandler: TransformHandler, + ) { + this.theme = game.config().theme(); + this.loadIcon(); + } + + private loadIcon() { + const img = new Image(); + img.src = artilleryIcon; + img.onload = () => { + this.artilleryIconImage = img; + this.textureCache.clear(); + }; + } + + shouldTransform(): boolean { + // Like StructureLayer: we handle transforms manually via screen coordinates + return false; + } + + async init() { + window.addEventListener("resize", () => this.resizeCanvas()); + await this.setupRenderer(); + } + + async setupRenderer() { + this.renderer = new PIXI.WebGLRenderer(); + this.pixiCanvas = document.createElement("canvas"); + this.pixiCanvas.width = window.innerWidth; + this.pixiCanvas.height = window.innerHeight; + this.stage = new PIXI.Container(); + await this.renderer.init({ + canvas: this.pixiCanvas, + resolution: 1, + width: this.pixiCanvas.width, + height: this.pixiCanvas.height, + clearBeforeRender: true, + backgroundAlpha: 0, + backgroundColor: 0x00000000, + }); + } + + resizeCanvas() { + if (this.renderer?.view) { + this.pixiCanvas.width = window.innerWidth; + this.pixiCanvas.height = window.innerHeight; + this.renderer.resize(innerWidth, innerHeight, 1); + } + } + + tick() { + const updates = this.game.updatesSinceLastTick(); + const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; + + for (const u of unitUpdates) { + const unitView = this.game.unit(u.id); + if (unitView === undefined) continue; + + if (unitView.type() !== UnitType.Artillery) { + // Check if this is a shell - artillery is firing + if (unitView.type() === UnitType.Shell && unitView.isActive()) { + // Find which artillery fired by checking lastTile (origin of shell) + const shellOriginTile = unitView.lastTile(); + const artilleryUnits = this.game.units(UnitType.Artillery); + for (const artillery of artilleryUnits) { + if ( + artillery.owner() === unitView.owner() && + artillery.tile() === shellOriginTile + ) { + // Mark this specific artillery as recently fired + this.lastFiredTick.set(artillery.id(), this.game.ticks()); + break; // Found the firing artillery + } + } + } + continue; + } + + if (unitView.isActive()) { + if (!this.seenUnits.has(unitView.id())) { + // New artillery unit + this.seenUnits.add(unitView.id()); + const sprite = this.createSprite(unitView); + this.renders.push(new ArtilleryRenderInfo(unitView, sprite)); + } else { + // Update health bar for existing unit + const render = this.renders.find( + (r) => r.unit.id() === unitView.id(), + ); + if (render) { + this.updateHealthBar(render); + } + } + } else { + // Unit removed + this.removeUnit(unitView.id()); + } + } + } + + private removeUnit(unitId: number) { + const idx = this.renders.findIndex((r) => r.unit.id() === unitId); + if (idx !== -1) { + const render = this.renders[idx]; + render.pixiSprite.destroy(); + if (render.healthBarGraphics) { + render.healthBarGraphics.destroy(); + render.healthBarGraphics = null; + } + this.renders.splice(idx, 1); + this.seenUnits.delete(unitId); + } + } + + renderLayer(mainContext: CanvasRenderingContext2D) { + if (!this.renderer) return; + + // Update all sprite positions and scales + for (const render of this.renders) { + this.updateSpritePosition(render); + this.updateHealthBar(render); + } + + this.renderer.render(this.stage); + mainContext.drawImage(this.renderer.canvas, 0, 0); + } + + private updateSpritePosition(render: ArtilleryRenderInfo) { + const tile = render.unit.tile(); + const worldX = this.game.x(tile); + const worldY = this.game.y(tile); + const screenPos = this.transformHandler.worldToScreenCoordinates( + new Cell(worldX, worldY), + ); + render.pixiSprite.x = Math.floor(screenPos.x + 0.5); + render.pixiSprite.y = Math.floor(screenPos.y + 0.5); + render.pixiSprite.scale.set(this.iconScreenScale()); + + // Flash red background when recently fired + const lastFired = this.lastFiredTick.get(render.unit.id()) ?? 0; + const ticksSinceFired = this.game.ticks() - lastFired; + const isFiring = ticksSinceFired < FIRING_FLASH_DURATION; + + const texture = isFiring + ? this.createFiringTexture(render.unit) + : this.createTexture(render.unit); + if (render.pixiSprite.texture !== texture) { + render.pixiSprite.texture = texture; + } + } + + private iconScreenScale(): number { + const s = this.transformHandler.scale; + if (s <= ICON_GROW_ZOOM_THRESHOLD) { + return (Math.min(1, s) / ICON_TEXTURE_QUALITY) * SIZE_SCALE; + } + return (s / ICON_GROW_ZOOM_THRESHOLD / ICON_TEXTURE_QUALITY) * SIZE_SCALE; + } + + private createSprite(unit: UnitView): PIXI.Sprite { + const texture = this.createTexture(unit); + const sprite = new PIXI.Sprite(texture); + sprite.anchor.set(0.5, 0.5); + this.stage.addChild(sprite); + return sprite; + } + + private createTexture(unit: UnitView): PIXI.Texture { + return this.createTextureWithBackground( + unit, + "#c9dbff", + this.textureCache, + "", + ); + } + + private createFiringTexture(unit: UnitView): PIXI.Texture { + return this.createTextureWithBackground( + unit, + "#b86b6b", + this.firingTextureCache, + "-firing", + ); + } + + private createTextureWithBackground( + unit: UnitView, + backgroundColor: string, + cache: Map, + cacheSuffix: string, + ): PIXI.Texture { + const border = this.theme.borderColor(unit.owner()); + const borderColor = border.darken(0.17).toRgbString(); + const level = unit.level ? unit.level() : 1; + const cacheKey = `${unit.owner().id()}-${borderColor}-${level}${cacheSuffix}`; + + if (cache.has(cacheKey)) { + return cache.get(cacheKey)!; + } + + const CANVAS_PX = Math.max(1, Math.round(ICON_DIM * ICON_TEXTURE_QUALITY)); + const canvas = document.createElement("canvas"); + canvas.width = CANVAS_PX; + canvas.height = CANVAS_PX; + const ctx = canvas.getContext("2d")!; + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.scale(ICON_TEXTURE_QUALITY, ICON_TEXTURE_QUALITY); + + // Draw background square + const pad = 0.5; + ctx.beginPath(); + ctx.rect(pad, pad, ICON_DIM - pad * 2, ICON_DIM - pad * 2); + ctx.fillStyle = backgroundColor; + ctx.fill(); + ctx.strokeStyle = borderColor; + ctx.lineWidth = 1; + ctx.stroke(); + + // Draw icon + if (this.artilleryIconImage && this.artilleryIconImage.complete) { + const colored = this.getImageColored( + this.artilleryIconImage, + borderColor, + ); + const padded = 4; + const maxW = ICON_DIM - padded * 2; + const maxH = ICON_DIM - padded * 2; + const iw = Math.max(1, colored.width); + const ih = Math.max(1, colored.height); + const baseScale = Math.min(maxW / iw, maxH / ih); + const factor = 1.4; + const dw = Math.min( + ICON_DIM, + Math.max(1, Math.round(iw * baseScale * factor)), + ); + const dh = Math.min( + ICON_DIM, + Math.max(1, Math.round(ih * baseScale * factor)), + ); + const dx = Math.round((ICON_DIM - dw) / 2); + const dy = Math.round((ICON_DIM - dh) / 2); + ctx.drawImage(colored, dx, dy, dw, dh); + } + + // Draw level indicator stars in top-left corner + if (level >= 1 && level <= 3) { + const tierColor = "#CD7F32"; /* bronze */ + const starSize = 4; + const spacing = 0.3; + const padding = 1; + const startX = padding + starSize / 2; + const startY = padding + starSize / 2; + + ctx.fillStyle = tierColor; + for (let i = 0; i < level; i++) { + const x = startX + i * (starSize + spacing); + this.drawStar(ctx, x, startY, starSize); + } + } + + const texture = PIXI.Texture.from(canvas); + cache.set(cacheKey, texture); + return texture; + } + + private drawStar( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + size: number, + ) { + const spikes = 5; + const outerRadius = size / 2; + const innerRadius = outerRadius * 0.4; + let rot = (Math.PI / 2) * 3; + const step = Math.PI / spikes; + + ctx.beginPath(); + ctx.moveTo(cx, cy - outerRadius); + + for (let i = 0; i < spikes; i++) { + let x = cx + Math.cos(rot) * outerRadius; + let y = cy + Math.sin(rot) * outerRadius; + ctx.lineTo(x, y); + rot += step; + + x = cx + Math.cos(rot) * innerRadius; + y = cy + Math.sin(rot) * innerRadius; + ctx.lineTo(x, y); + rot += step; + } + + ctx.lineTo(cx, cy - outerRadius); + ctx.closePath(); + ctx.fill(); + } + + private getImageColored( + image: HTMLImageElement, + color: string, + ): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = color; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.globalCompositeOperation = "destination-in"; + ctx.drawImage(image, 0, 0); + return canvas; + } + + redraw() { + // Clear and rebuild all sprites + for (const render of this.renders) { + render.pixiSprite.destroy(); + } + this.renders = []; + this.seenUnits.clear(); + + // Re-add all active artillery + const artilleryUnits = this.game.units(UnitType.Artillery); + for (const unit of artilleryUnits) { + if (unit.isActive()) { + this.seenUnits.add(unit.id()); + const sprite = this.createSprite(unit); + this.renders.push(new ArtilleryRenderInfo(unit, sprite)); + } + } + } + + private updateHealthBar(render: ArtilleryRenderInfo) { + const unit = render.unit; + const maxHealth = unit.effectiveMaxHealth(); + + if (!maxHealth) return; // No health for this unit type + + // Only show health bar if damaged and active + if (!unit.isActive() || unit.health() >= maxHealth || unit.health() <= 0) { + if (render.healthBarGraphics) { + render.healthBarGraphics.destroy(); + render.healthBarGraphics = null; + } + return; + } + + // Create health bar if it doesn't exist + if (!render.healthBarGraphics) { + render.healthBarGraphics = new PIXI.Graphics(); + this.stage.addChild(render.healthBarGraphics); + } + + const graphics = render.healthBarGraphics; + graphics.clear(); + + // Get the scaled icon size + const spriteScale = render.pixiSprite.scale.x; // Assumes uniform scaling + const scaledIconSize = ICON_DIM * spriteScale; + + // Bar dimensions scale with the icon + const barWidth = scaledIconSize * 3; // 300% of icon width + const barHeight = scaledIconSize * 0.3; // 30% of icon height + const gap = scaledIconSize * 1.8; + const yOffset = -(scaledIconSize / 2 + barHeight + gap); // Above the icon with scaled gap + + // Position relative to sprite center + graphics.x = render.pixiSprite.x; + graphics.y = render.pixiSprite.y + yOffset; + + // Background (black border) + graphics.beginFill(0x000000, 1); + graphics.drawRect(-barWidth / 2 - 1, -1, barWidth + 2, barHeight + 2); + graphics.endFill(); + + // Health fill (color based on health percentage) + const healthPercent = unit.health() / maxHealth; + const colorIndex = Math.min( + HEALTH_BAR_COLORS.length - 1, + Math.floor(healthPercent * HEALTH_BAR_COLORS.length), + ); + const fillColor = HEALTH_BAR_COLORS[colorIndex]; + + graphics.beginFill(fillColor, 1); + graphics.drawRect( + -barWidth / 2, + 0, + Math.max(1, healthPercent * barWidth), + barHeight, + ); + graphics.endFill(); + } +} diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 98a78b66d..c89f1f850 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -1,5 +1,6 @@ import { LitElement, css, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; +import artilleryIcon from "../../../../proprietary/images/artillery-battery.png"; import doomsdayDeviceIcon from "../../../../proprietary/images/doomsdayicon.png"; import researchLabIcon from "../../../../proprietary/images/researchlab.png"; import airfieldIcon from "../../../../resources/images/AirfieldIcon.svg"; @@ -27,12 +28,13 @@ import { import { Gold, UnitType, UpgradeType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { + isStackableStructure, + isTechUpgradeableStructure, isUnitAvailable, - isUpgradeableStructure, isUpgradeableUnit, - maxStructureLevel, + maxStackCount, maxUnitLevel, - playerMaxStructureLevel, + playerMaxStructureTechLevel, playerMaxUnitLevel, } from "../../../core/game/Upgradeables"; import { ToggleBomberUpgradeModeEvent } from "../../events/ToggleBomberUpgradeModeEvent"; @@ -91,6 +93,13 @@ const buildTable: BuildItemDisplay[][] = [ key: "unit_type.submarine", countable: true, }, + { + unitType: UnitType.Artillery, + icon: artilleryIcon, + description: "build_menu.desc.artillery", + key: "unit_type.artillery", + countable: true, + }, { unitType: UnitType.City, icon: cityIcon, @@ -264,6 +273,7 @@ export class BuildMenu extends LitElement { buildFighterJet: "Digit8", buildWarship: "Digit9", buildSubmarine: "Digit0", + buildArtillery: "Digit4", buildCity: "KeyY", buildPort: "KeyU", buildAirfield: "KeyI", @@ -284,6 +294,7 @@ export class BuildMenu extends LitElement { [keybinds.buildFighterJet]: UnitType.FighterJet, [keybinds.buildWarship]: UnitType.Warship, [keybinds.buildSubmarine]: UnitType.Submarine, + [keybinds.buildArtillery]: UnitType.Artillery, [keybinds.buildCity]: UnitType.City, [keybinds.buildPort]: UnitType.Port, [keybinds.buildAirfield]: UnitType.Airfield, @@ -331,8 +342,12 @@ export class BuildMenu extends LitElement { } static styles = css` + * { + box-sizing: border-box; + } :host { display: block; + width: 100%; } .build-menu-prompt { display: flex; @@ -344,47 +359,34 @@ export class BuildMenu extends LitElement { text-align: center; } .build-menu { - background-color: transparent; - padding: 0px; - display: flex; - flex-direction: column; - align-items: flex-start; - max-width: 95vw; - max-height: 95vh; - overflow-y: auto; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + width: 100%; } .build-row { - display: flex; - justify-content: left; - flex-wrap: wrap; - width: 100%; + display: contents; } .build-button { position: relative; - width: 120px; - height: 50px; + height: 44px; border: 2px solid var(--ui-panel-border); - /* Darker idle surface to improve separation */ background: var(--ui-primary); - color: var(--ui-text-accent); /* submarine palette light blue */ + color: var(--ui-text-accent); border-radius: 6px; - box-shadow: - inset 0 0 10px rgba(0, 0, 0, 0.5), - 0 2px 6px rgba(0, 0, 0, 0.4); cursor: pointer; - transition: all 0.3s ease; + transition: all 0.15s ease; display: flex; flex-direction: row; - justify-content: flex-start; align-items: center; - margin: 4px; - padding: 5px; - gap: 8px; + padding: 0 6px; + gap: 6px; + overflow: hidden; } .build-button:not(:disabled):hover { - background-color: var(--ui-secondary); /* deeper navy on hover */ + background-color: var(--ui-secondary); transform: scale(1.02); - border-color: var(--ui-secondary); /* blue accent border */ + border-color: var(--ui-secondary); box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.6); @@ -394,11 +396,9 @@ export class BuildMenu extends LitElement { to bottom, var(--ui-secondary-hover), var(--ui-secondary) - ); /* pressed navy */ + ); transform: scale(0.98); - box-shadow: - inset 0 0 10px rgba(0, 0, 0, 0.7), - 0 1px 3px rgba(0, 0, 0, 0.3); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } .build-button:disabled { background-color: var(--ui-primary-disabled); @@ -414,102 +414,98 @@ export class BuildMenu extends LitElement { color: var(--ui-text-muted); } .selected-for-build { - border-color: var(--ui-secondary-hover); /* blue selection accent */ + border-color: var(--ui-secondary-hover); + background-color: var(--ui-secondary); box-shadow: 0 0 10px rgba(50, 98, 155, 0.65); } .build-icon { - width: 28px; - height: 28px; + width: 24px; + height: 24px; flex-shrink: 0; + object-fit: contain; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)); } .build-item-details { display: flex; flex-direction: column; align-items: flex-start; - gap: 2px; + justify-content: center; + flex: 1; + min-width: 0; + gap: 1px; } .build-name { - font-size: 11px; - font-weight: bold; - text-align: left; - line-height: 1.2; - color: var(--ui-text-accent); /* brighten primary label */ - font-family: monospace; - } - .build-description { - font-size: 0.6rem; - line-height: 1.2; + font-size: 10px; + font-weight: 600; + color: var(--ui-text-accent); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - word-break: break-word; - max-height: 2.4em; - color: var(--ui-text-muted); /* muted info */ + max-width: 100%; + line-height: 1.2; } .build-cost { font-size: 10px; + font-family: monospace; white-space: nowrap; - text-align: left; - color: var(--ui-text-accent); /* readable cost color */ + color: #fbbf24; + display: flex; + align-items: center; + gap: 2px; + line-height: 1.2; } - .build-count-chip { - position: absolute; - top: -5px; - right: -5px; - background-color: var(--ui-panel-shell-bottom); - color: var(--ui-text-light); + .build-description { + display: none; + } + .build-stats { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1px; + flex-shrink: 0; + } + .build-count { + font-size: 11px; + font-weight: bold; + color: rgba(255, 255, 255, 0.9); + background: rgba(0, 0, 0, 0.3); padding: 1px 5px; - border-radius: 10px; - font-size: 9px; - border: 1px solid var(--ui-border-muted); + border-radius: 3px; + font-family: monospace; } - .build-level-chip { + .build-stack { + display: none; + } + .build-stack-badge { position: absolute; - top: -5px; - left: -5px; - background-color: var(--ui-panel-shell-bottom); - color: var(--ui-text-light); + top: 2px; + right: 2px; + font-size: 10px; + color: #fff; + font-family: monospace; + font-weight: bold; + background: #1d4ed8; padding: 1px 5px; - border-radius: 10px; - font-size: 9px; - border: 1px solid var(--ui-border-muted); + border-radius: 3px; + border: 1px solid #3b82f6; + z-index: 2; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5); + } + .build-count-chip { + display: none; + } + .build-level-chip { + display: none; } .build-hotkey { position: absolute; bottom: 2px; right: 4px; - color: var(--ui-text-muted); /* subtle hint color */ + color: rgba(255, 255, 255, 0.5); font-size: 9px; - } - .build-button:not(:disabled):hover > .build-count-chip { - background-color: var(--ui-panel-shell-top); - border-color: var(--ui-border-muted); - } - .build-button:not(:disabled):hover > .build-level-chip { - background-color: var(--ui-panel-shell-top); - border-color: var(--ui-border-muted); - } - .build-button:not(:disabled):active > .build-count-chip { - background-color: var(--ui-panel-shell-bottom); - } - .build-button:not(:disabled):active > .build-level-chip { - background-color: var(--ui-panel-shell-bottom); - } - .build-button:disabled > .build-count-chip { - background-color: var(--ui-surface-dark); - border-color: var(--ui-border-muted); - cursor: not-allowed; - } - .build-button:disabled > .build-level-chip { - background-color: var(--ui-surface-dark); - border-color: var(--ui-border-muted); - cursor: not-allowed; - } - .build-count { - font-weight: bold; - font-size: 10px; + font-weight: 600; + pointer-events: none; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); } `; @@ -537,6 +533,11 @@ export class BuildMenu extends LitElement { case UnitType.HydrogenBomb: case UnitType.MIRV: return player.unitsOwned(UnitType.MissileSilo) > 0; + case UnitType.Artillery: + return ( + player.unitsOwned(UnitType.Factory) > 0 && + player.hasUpgrade(UpgradeType.ArtilleryResearch) + ); default: return true; } @@ -547,70 +548,79 @@ export class BuildMenu extends LitElement { .config() .unitInfo(item.unitType) .cost(this.game.myPlayer()!); - // Structures: use configured structure multiplier - if (isUpgradeableStructure(item.unitType)) { - const desired = this._desiredStructureLevel(item.unitType); + // Stackable structures: use stack count for cost calculation + if (isStackableStructure(item.unitType)) { + const stackCount = this._desiredStackCount(item.unitType); let structureCost = - desired <= 1 + stackCount <= 1 ? base : aggregateStructureBuildCost( this.game.config(), this.game.myPlayer()!, item.unitType, - desired, + stackCount, this.game.config().structureUpgradeCostMultiplier(item.unitType), ); - // Add bomber upgrade cost for airfields + // Add bomber upgrade cost for airfields (based on tech level, not stack) if (item.unitType === UnitType.Airfield) { - const bomberLevel = this._desiredUnitLevel(UnitType.Bomber); + const bomberLevel = this._structureTechLevel(UnitType.Airfield); structureCost += computeBomberUpgradeCost( this.game.config(), this.game.myPlayer()!, bomberLevel, - desired, + stackCount, ); } return structureCost; } // Units: use hardcoded costs from UnitUpgrades (aggregateStructureBuildCost handles this) if (isUpgradeableUnit(item.unitType)) { - const desired = this._desiredUnitLevel(item.unitType); - if (desired <= 1) return base; + const techLevel = playerMaxUnitLevel( + this.game.myPlayer()!, + item.unitType, + ); + if (techLevel <= 1) return base; // aggregateStructureBuildCost detects upgradeable units and uses hardcoded costs return aggregateStructureBuildCost( this.game.config(), this.game.myPlayer()!, item.unitType, - desired, + techLevel, 0, // multiplier ignored for upgradeable units ); } return base; } - private _desiredStructureLevel(type: UnitType): number { - // If a specific level is requested via the UI prop, use that (clamped by max level) + // Get the desired stack count for stackable structures + private _desiredStackCount(type: UnitType): number { + // If a specific level is requested via the UI prop, use that (clamped by max) const level = this.structureLevels[type]; if (level && level > 1) { - return Math.min(maxStructureLevel(type), level); + return Math.min(maxStackCount(type), level); } + // Read from localStorage (used for in-game communication, not persistence) try { - const raw = localStorage.getItem("buildSettings.levels"); + const raw = localStorage.getItem("buildSettings.stackCount"); if (!raw) return 1; const obj = JSON.parse(raw); const key = String(type); const val = obj?.[key]; if (typeof val !== "number" || val < 1) return 1; - // Use player-specific max level based on researched techs - const player = this.game?.myPlayer(); - const maxLevel = player ? playerMaxStructureLevel(player, type) : 1; - return Math.min(maxLevel, val); + return Math.min(maxStackCount(type), val); } catch (_) { return 1; } } + // Get the tech level for tech-upgradeable structures (SAM, Airfield) + private _structureTechLevel(type: UnitType): number { + const player = this.game?.myPlayer(); + if (!player) return 1; + return playerMaxStructureTechLevel(player, type); + } + private _desiredUnitLevel(type: UnitType): number { try { const raw = localStorage.getItem("unitUpgradeSettings.levels"); @@ -635,7 +645,94 @@ export class BuildMenu extends LitElement { if (!player) { return "?"; } - return player.units(item.unitType).length.toString(); + // Use unitsOwned() to get the correct count including stacked structures + return player.unitsOwned(item.unitType).toString(); + } + + private getUnitDisplayName(unitType: UnitType, baseName: string): string { + const player = this.game?.myPlayer(); + if (!player) return baseName; + + // Handle combat units with tech upgrades + if (isUpgradeableUnit(unitType)) { + const level = playerMaxUnitLevel(player, unitType); + + // Only Fighters use "Gen X" naming + if (unitType === UnitType.FighterJet && level > 1) { + return `Gen ${level} ${baseName}`; + } + + // Warships have specific names per level + if (unitType === UnitType.Warship) { + switch (level) { + case 1: + return baseName; // "Warship" + case 2: + return "Cruiser"; + case 3: + return "Aegis Warship"; + default: + return baseName; + } + } + + // Submarines have specific names per level + if (unitType === UnitType.Submarine) { + switch (level) { + case 1: + return "Diesel Sub"; + case 2: + return "Tactical Sub"; + case 3: + return "Attack Sub"; + default: + return baseName; + } + } + + // Bombers have specific names per level + if (unitType === UnitType.Bomber) { + switch (level) { + case 1: + return baseName; // "Bomber" + case 2: + return "Heavy Bomber"; + case 3: + return "Supersonic Bomber"; + default: + return baseName; + } + } + + return baseName; + } + + // Handle tech-upgradeable structures (SAM, Airfield) + if (isTechUpgradeableStructure(unitType)) { + const techLevel = playerMaxStructureTechLevel(player, unitType); + const stackCount = this._desiredStackCount(unitType); + + let name = baseName; + if (unitType === UnitType.SAMLauncher && techLevel > 1) { + name = + techLevel === 2 + ? "Radar SAM" + : techLevel === 3 + ? "Strategic SAM" + : baseName; + } + + // Do not prefix stack count in the label; chip handles it + return name; + } + + // Handle other stackable structures + if (isStackableStructure(unitType)) { + // Do not prefix stack count in the label; chip handles it + return baseName; + } + + return baseName; } public onBuildSelected = (item: BuildItemDisplay) => { @@ -674,16 +771,17 @@ export class BuildMenu extends LitElement { (row) => html`
${row.map((item) => { - const name = item.key + const baseName = item.key ? translateText(item.key) : String(item.unitType); const price = this.game && this.game.myPlayer() ? this.cost(item) : 0; - const desiredLevel = isUpgradeableStructure(item.unitType) - ? this._desiredStructureLevel(item.unitType) - : isUpgradeableUnit(item.unitType) - ? this._desiredUnitLevel(item.unitType) - : 1; + + const displayName = this.getUnitDisplayName( + item.unitType, + baseName, + ); + const desiredStack = this._desiredStackCount(item.unitType); return html` `; })} diff --git a/src/client/graphics/layers/ControlPanel2.ts b/src/client/graphics/layers/ControlPanel2.ts index c28410606..02dbb07c8 100644 --- a/src/client/graphics/layers/ControlPanel2.ts +++ b/src/client/graphics/layers/ControlPanel2.ts @@ -4,16 +4,11 @@ import multiBuildIcon from "../../../../resources/images/MultiBuildIcon.svg"; import upgradeArrowIcon from "../../../../resources/images/UpgradeArrowIcon.svg"; import type { EventBus } from "../../../core/EventBus"; import type { Gold, PlayerID } from "../../../core/game/Game"; -import { PlayerType, UnitType, UpgradeType } from "../../../core/game/Game"; -import type { - GameView, - PlayerView, - UnitView, -} from "../../../core/game/GameView"; +import { UnitType, UpgradeType } from "../../../core/game/Game"; +import type { GameView } from "../../../core/game/GameView"; import { isUnitAvailable, maxStructureLevel, - maxUnitLevel, playerMaxStructureLevel, playerMaxUnitLevel, } from "../../../core/game/Upgradeables"; @@ -34,13 +29,7 @@ import { ToggleUpgradeModeEvent } from "../../events/ToggleUpgradeModeEvent"; import { AttackRatioEvent } from "../../InputHandler"; import "../../StatisticsModal"; // ensure statistics modal is registered import { - SendAllianceRequestIntentEvent, SendBomberIntentEvent, - SendBreakAllianceIntentEvent, - SendDeclareWarIntentEvent, - SendEmbargoIntentEvent, - SendPeaceRequestIntentEvent, - SendSetAutoBombingEvent, SendSetInvestmentRateEvent, SendSetResearchInvestmentEvent, SendSetRoadInvestmentEvent, @@ -106,29 +95,11 @@ export class ControlPanel2 extends LitElement implements Layer { private init_: boolean = false; @state() - private activeTab: - | "Build" - | "Attack" - | "Economy" - | "Bombers" - | "Trade" - | "Diplomacy" = "Build"; - - @state() - private _lastAirfieldCount: number = 0; - - @state() - private _lastPlayersHash: string = ""; - - @state() - private _reachablePlayersHash: string = ""; + private activeTab: "Build" | "Attack" | "Economy" = "Build"; @state() private _hasAirfields: boolean = false; - @state() - private _highlightBombersTab: boolean = false; - @state() private _currentTargetPlayerId: PlayerID | null = null; @@ -138,20 +109,11 @@ export class ControlPanel2 extends LitElement implements Layer { @state() private _currentTargetPlayerName: string | null = null; - @state() - private _bomberPreferClosest: boolean = true; - - @state() - private _isAutoBombingEnabled: boolean = false; - - @state() - private _lastSelectedBomberTarget: PlayerID | null = null; - @state() private _multibuildEnabled: boolean = false; @state() - private _structureLevels: Record = {}; + private _structureLevels: Record = {}; // Stack counts for structures @state() private _unitLevels: Record = {}; @@ -159,6 +121,18 @@ export class ControlPanel2 extends LitElement implements Layer { @state() private _uiSelectedStructures: UnitType[] = []; + // Cache for trade demand to prevent flickering tooltips + @state() + private _tradeDemandCache: { + label: string; + color: string; + queueLen: number; + availableShips: number; + myShipCount: number; + tooltip: string; + timestamp: number; + } | null = null; + private unitIconMap: { [key: string]: string } = { City: "/images/CityIconWhite.svg", Hospital: "/images/HospitalIconWhite.svg", @@ -212,6 +186,7 @@ export class ControlPanel2 extends LitElement implements Layer { UnitType.FighterJet, UnitType.Warship, UnitType.Submarine, + UnitType.Artillery, ]; private readonly StructureTypes: UnitType[] = [ @@ -357,15 +332,7 @@ export class ControlPanel2 extends LitElement implements Layer { }); this._updatePlayerHashAndRefresh(); // Initial hash calculation - // Load structure levels - try { - const raw = localStorage.getItem("buildSettings.levels"); - if (raw) { - this._structureLevels = JSON.parse(raw); - } - } catch (e) { - console.warn("Failed to load build settings", e); - } + // Structure stack counts default to 1 (no persistence between games) // Load unit levels try { @@ -378,21 +345,7 @@ export class ControlPanel2 extends LitElement implements Layer { } } - private _updatePlayerHashAndRefresh() { - const currentPlayersHash = this.game - .players() - .map((p) => p.id()) - .sort() - .join(","); - - if (this._lastPlayersHash !== currentPlayersHash) { - this._lastPlayersHash = currentPlayersHash; - // Only refresh the list if the relevant tab is active - if (this.activeTab === "Bombers") { - this._refreshBomberPlayerLists(); - } - } - } + private _updatePlayerHashAndRefresh() {} tick() { if (this.init_) { @@ -544,27 +497,6 @@ export class ControlPanel2 extends LitElement implements Layer { // Track relevant state for dynamic updates const currentAirfieldCount = player.units(UnitType.Airfield).length; - this._hasAirfields = currentAirfieldCount > 0; - - if (this.activeTab === "Bombers" && this.game.ticks() % 10 === 0) { - const currentReachablePlayersHash = this._getPlayersInAirfieldRange() - .map((p) => p.id()) - .sort() - .join(","); - - if ( - this._lastAirfieldCount !== currentAirfieldCount || - this._reachablePlayersHash !== currentReachablePlayersHash - ) { - this._refreshBomberPlayerLists(); - this._lastAirfieldCount = currentAirfieldCount; - this._reachablePlayersHash = currentReachablePlayersHash; - } - } - - if (this.activeTab === "Bombers" && !this._hasAirfields) { - this.activeTab = "Build"; // Changed from "Controls" - } this.requestUpdate(); @@ -782,62 +714,6 @@ export class ControlPanel2 extends LitElement implements Layer { return player?.hasUpgrade?.(UpgradeType.Roads) ?? false; } - private _getPlayersInAirfieldRange(): PlayerView[] { - const myPlayer = this.game.myPlayer(); - if (!myPlayer?.isAlive()) { - return []; - } - - const myAirfields = myPlayer - .units(UnitType.Airfield) - .filter((u) => u.isActive()); - if (myAirfields.length === 0) { - return []; - } - - const reachablePlayers = new Map(); - - const structureIndex = this.game.getStructureIndex(); - - for (const airfield of myAirfields) { - const bomberRange = this.game - .config() - .bomberTargetRange(airfield.bomberLevel()); - const airfieldPos = { - x: this.game.x(airfield.tile()), - y: this.game.y(airfield.tile()), - }; - const nearbyStructures = structureIndex.getInRange( - airfieldPos.x, - airfieldPos.y, - bomberRange, - ); - - for (const structure of nearbyStructures) { - const owner = structure.owner(); - if ( - owner && - owner.isPlayer() && - owner.id() !== myPlayer.id() && // Prevent self-targeting - !myPlayer.isFriendly(owner) && - owner.type() !== PlayerType.Bot - ) { - if (!reachablePlayers.has(owner.id())) { - reachablePlayers.set(owner.id(), owner); - } - } - } - } - - return Array.from(reachablePlayers.values()).sort((a, b) => - a.name().localeCompare(b.name()), - ); - } - - private _refreshBomberPlayerLists() { - this.populateBomberForm(); // Populates the main player select list - } - updated(changedProperties: Map) { if (changedProperties.has("isOpen")) { if (this.isOpen) { @@ -847,26 +723,6 @@ export class ControlPanel2 extends LitElement implements Layer { } } - if (this.activeTab === "Bombers") { - if ( - changedProperties.has("activeTab") || - changedProperties.has("_hasAirfields") - ) { - this._refreshBomberPlayerLists(); - } - } - - if (changedProperties.has("_hasAirfields")) { - const oldHasAirfields = changedProperties.get("_hasAirfields"); - if (this._hasAirfields && !oldHasAirfields) { - // Airfields just became available, highlight the tab - this._highlightBombersTab = true; - setTimeout(() => { - this._highlightBombersTab = false; - }, 3000); // Highlight for 3 seconds - } - } - // Apply translations to tooltips after rendering this.querySelectorAll("[data-i18n-title]").forEach((el) => { const key = el.getAttribute("data-i18n-title"); @@ -876,55 +732,6 @@ export class ControlPanel2 extends LitElement implements Layer { }); } - populateBomberForm() { - const playerSelect = this.querySelector( - "#bomber-player-select", - ) as HTMLSelectElement | null; - if (!this.game || !playerSelect) return; - - const me = this.game.myPlayer(); - if (!me) return; - - const playersToDisplay: PlayerView[] = this._getPlayersInAirfieldRange(); - - if (playersToDisplay.length === 0) { - playerSelect.innerHTML = ``; - playerSelect.disabled = true; - this._lastSelectedBomberTarget = null; // Clear selection if no targets are available - } else { - const optsPlayers = playersToDisplay - .map((p) => ``) - .join(""); - playerSelect.innerHTML = optsPlayers; - playerSelect.disabled = false; - - const stillAValidTarget = playersToDisplay.some( - (p) => p.id() === this._lastSelectedBomberTarget, - ); - - if (stillAValidTarget) { - playerSelect.value = this._lastSelectedBomberTarget as string; - } else { - // If the last target is no longer valid, default to the first in the list and update the state - this._lastSelectedBomberTarget = playerSelect.value; - } - } - } - - handleBomberIntent() { - const playerSelect = this.querySelector( - "#bomber-player-select", - ) as HTMLSelectElement; - - if (!playerSelect || this._uiSelectedStructures.length === 0) return; - - const targetID = String(playerSelect.value); - // Use the state variable instead of querying the DOM - const structures = [...this._uiSelectedStructures]; - - this.sendBomberIntent(targetID, structures, this._bomberPreferClosest); - } - sendBomberIntent( targetID: string | null, structures: UnitType[] | null, @@ -933,7 +740,6 @@ export class ControlPanel2 extends LitElement implements Layer { if (!this.eventBus) return; this._currentTargetPlayerId = targetID; this._currentTargetStructureTypes = structures ?? []; - this._bomberPreferClosest = preferClosest; if (targetID) { const targetPlayer = this.game.players().find((p) => p.id() === targetID); this._currentTargetPlayerName = targetPlayer ? targetPlayer.name() : null; @@ -945,45 +751,6 @@ export class ControlPanel2 extends LitElement implements Layer { ); } - _startAutoBombing() { - this._isAutoBombingEnabled = true; - this.eventBus.emit(new SendSetAutoBombingEvent(true)); - // Clear any manual target when auto-bombing is enabled - this.sendBomberIntent(null, null, true); - } - - async _stopAutoBombing() { - this._isAutoBombingEnabled = false; - this.eventBus.emit(new SendSetAutoBombingEvent(false)); - // Clear any manual target when auto-bombing is disabled - this.sendBomberIntent(null, null, true); - - await this.updateComplete; // Wait for the UI to update - - this._refreshBomberPlayerLists(); // NOW refresh the list - } - - handleStructureChange(e: Event) { - const checkbox = e.target as HTMLInputElement; - const value = checkbox.value as UnitType; - - if (checkbox.checked) { - if (!this._uiSelectedStructures.includes(value)) { - this._uiSelectedStructures = [...this._uiSelectedStructures, value]; - } - } else { - this._uiSelectedStructures = this._uiSelectedStructures.filter( - (s) => s !== value, - ); - } - this.requestUpdate(); - } - - private _handleBomberTargetChange(e: Event) { - const select = e.target as HTMLSelectElement; - this._lastSelectedBomberTarget = select.value; - } - private _handleMultibuildToggle() { this._multibuildEnabled = !this._multibuildEnabled; this.uiState.multibuildEnabled = this._multibuildEnabled; @@ -1071,6 +838,7 @@ export class ControlPanel2 extends LitElement implements Layer { UnitType.FighterJet, UnitType.Submarine, UnitType.Bomber, + UnitType.Artillery, ]; if (typeof openFn !== "function") { console.warn("UnitUpgradeSettingsModal missing open() method"); @@ -1098,9 +866,7 @@ export class ControlPanel2 extends LitElement implements Layer { return el; } - private _changeTab( - tab: "Build" | "Attack" | "Economy" | "Bombers" | "Trade" | "Diplomacy", - ) { + private _changeTab(tab: "Build" | "Attack" | "Economy") { this.activeTab = tab; if (this.uiState.pendingBuildUnitType) { this.uiState.pendingBuildUnitType = null; @@ -1354,40 +1120,6 @@ export class ControlPanel2 extends LitElement implements Layer { > Economy - - - ${this._hasAirfields - ? html` - - ` - : ""}
-
- ${this.activeTab === "Bombers" - ? html` - -
- -
- ${ - this._isAutoBombingEnabled - ? html` -
-
- AUTO-BOMBING ACTIVE -
-
- Bombers are automatically targeting nearby - enemies. -
-
- ` - : html` -
- - -
- Priority -
- -
- -
-
-
- -
- -
- ${[ - UnitType.City, - UnitType.DefensePost, - UnitType.SAMLauncher, - UnitType.MissileSilo, - UnitType.Port, - UnitType.Airfield, - UnitType.Hospital, - UnitType.Academy, - UnitType.ResearchLab, - UnitType.Factory, - UnitType.DoomsdayDevice, - ].map((s) => { - const isSelected = - this._uiSelectedStructures.includes(s); - return html` - - `; - })} -
-
- - -
- - -
- ${this._currentTargetPlayerId && - this._currentTargetStructureTypes.length > 0 - ? html` -
- ${this - ._currentTargetPlayerName} - ${this._bomberPreferClosest - ? "Closest" - : "Furthest"} -
- ${this._currentTargetStructureTypes.map( - (structType) => html` - - `, - )} -
-
- ` - : html`No target set`} -
-
- ` - } -
- - -
- -
-
- Upgrade Bombers -
-
- -
- - -
-
- Auto-Bomb -
-
- ${ - this._isAutoBombingEnabled - ? html` - - ` - : html` - - ` - } -
-
-
-
- ` - : ""} - ${this.activeTab === "Build" - ? html`
` - : ""} +
${this.activeTab === "Build" ? html` -
+ +
-
-
- - ${this.uiState.pendingBuildUnitType - ? `Set ${this.uiState.pendingBuildUnitType} Level` - : "Select a structure..."} - - - -
-
+
+ Stack: + + + ${this.uiState.pendingBuildUnitType + ? `${this._structureLevels[this.uiState.pendingBuildUnitType] || 1}` + : "1"} + + +
+
+ ${this._renderTradeDemand()}
+ + +
-
-
- - ${this.uiState.pendingBuildUnitType - ? `Set ${this.uiState.pendingBuildUnitType} Level` - : "Select a unit..."} - - - -
-
-
` : ""} - ${this.activeTab === "Trade" ? this._renderTradeTab() : ""} - ${this.activeTab === "Diplomacy" ? this.renderDiplomacyTab() : ""}
`; } - private _renderTradeTab() { + private _renderTradeDemand() { const me = this.game.myPlayer(); if (!me) return html``; - const ships = me.units(UnitType.TradeShip).filter((u) => u.isActive()); - const ports = me.units(UnitType.Port).filter((p) => p.isActive()); - const ticks = this.game.ticks(); - const delay = this.game.config().tradeShipReplacementDelayTicks(); - // Multi-build: gather all pending construction due ticks across ports - const pendingEntries: Array<{ port: UnitView; due: number }> = []; - for (const p of ports) { - const arr: number[] = (p as any).pendingTradeShipDueTicks?.() ?? []; - for (const due of arr) { - if (due > ticks) pendingEntries.push({ port: p, due }); - } - } - pendingEntries.sort((a, b) => a.due - b.due); - const pendingRows = pendingEntries.map(({ port, due }, idx) => { - const remaining = due - ticks; - const pct = Math.min( - 100, - Math.max(0, Math.round(((delay - remaining) / delay) * 100)), - ); - return html`
-
- Cargo Ship #${idx + 1} (Port #${port.id()}) constructingโ€ฆ -
-
-
-
-
`; - }); - const mapHeight = this.game.height(); - const rows = ships.map((ship) => { - const tile = ship.tile(); - const x = this.game.x(tile); - const topOriginY = this.game.y(tile); - const y = mapHeight - 1 - topOriginY; // display with bottom-left origin - const status = this._computeTradeShipStatus(ship); + // Hide trade demand if player has no ports (trading not yet available) + const myPorts = me.units(UnitType.Port).filter((u) => u.isActive()); + if (myPorts.length === 0) return html``; + + // Count MY trade ships (not global) + const myTradeShips = me + .units(UnitType.TradeShip) + .filter((u) => u.isActive()); + const myShipCount = myTradeShips.length; + + // If I have no trade ships, show "No Ships" + if (myShipCount === 0) { + const icon = html` + + + + + + `; return html`
-
- Ship #${ship.id()} - ${status} -
-
(${x}, ${y})
+ ${icon} + Trade Demand: + + No Ships +
`; - }); + } - // Compute demand indicator (global: all cargo ships, not just mine) - const allTradeShips = this.game - .units(UnitType.TradeShip) - .filter((u) => u.isActive()); - const totalShips = allTradeShips.length; - const availableShips = allTradeShips.filter((s) => { + // Count ships available (idle at my ports) + const availableShips = myTradeShips.filter((s) => { const isReturning = s.returning(); const phase = s.tradePhase(); const hasTarget = s.targetUnitId() !== undefined; const dockOwner = s.dockedAtPortOwner(); - return !isReturning && phase === null && !hasTarget && dockOwner !== null; + // Available = at my port, not assigned, not in transit + return ( + !isReturning && + phase === null && + !hasTarget && + dockOwner?.smallID() === me.smallID() + ); }).length; + const queueLen = me.tradeDemandQueueLength(); - const denom = Math.max(1, totalShips); - const queuedPct = queueLen / denom; - const availablePct = availableShips / denom; + + // Only update cache if values changed significantly (reduce re-render flicker) + const now = Date.now(); + const cacheValid = + this._tradeDemandCache !== null && + this._tradeDemandCache.queueLen === queueLen && + this._tradeDemandCache.availableShips === availableShips && + this._tradeDemandCache.myShipCount === myShipCount && + now - this._tradeDemandCache.timestamp < 2000; // 2 second cache + + if (cacheValid) { + const cached = this._tradeDemandCache!; + const icon = html` + + + + + + `; + return html` +
+ ${icon} + Trade Demand: + + ${cached.label} + +
+ `; + } + + // Compare queue vs MY ships (not global) + const queueRatio = queueLen / Math.max(1, myShipCount); + const availableRatio = availableShips / Math.max(1, myShipCount); + let demandLabel = "Medium"; let demandColor = "var(--ui-text-default)"; - if (queuedPct > 0.5) { + + // High demand = lots of routes waiting, need more ships + if (queueRatio > 2) { demandLabel = "Very High"; demandColor = "var(--ui-alert)"; - } else if (queuedPct > 0.25) { + } else if (queueRatio > 1) { demandLabel = "High"; demandColor = "var(--ui-warning)"; - } else if (availablePct > 0.5) { - demandLabel = "Very Low"; - demandColor = "var(--ui-info)"; - } else if (availablePct > 0.25) { + } else if (availableRatio > 0.5) { + // Low demand = most ships idle, surplus capacity demandLabel = "Low"; demandColor = "var(--ui-success)"; - } else { - demandLabel = "Medium"; - demandColor = "var(--ui-text-default)"; + } else if (queueLen === 0 && availableShips > 0) { + demandLabel = "Very Low"; + demandColor = "var(--ui-info)"; } - return html` -
-
-

Cargo Ships

-
- - Trade Demand: ${demandLabel} - -
-
- ${pendingRows.length > 0 - ? html`
-

Under Construction

- -
${pendingRows}
-
` - : ""} - ${ships.length > 0 - ? html`
${rows}
` - : ships.length === 0 && pendingRows.length === 0 - ? html`
No active cargo ships.
` - : ""} - - -
-

Embargo Management

-
- - -
-
-
- `; - } - - private renderDiplomacyTab() { - const me = this.game.myPlayer(); - if (!me) return html``; - - const players = this.game - .players() - .filter( - (p) => - p.isAlive() && - p.id() !== me.id() && - (p.type() === PlayerType.Human || p.type() === PlayerType.FakeHuman), - ); - - // Icons and colors reused from radial menu - const warIcon = "/images/waricon.png"; - const peaceIcon = "/images/dove.png"; - const allianceIcon = "/images/AllianceIconWhite.svg"; - const traitorIcon = "/images/TraitorIconWhite.svg"; - - // Colors matching radial menu - const warColor = "#8B0000"; // dark red for declare war - const peaceColor = "#e5e7eb"; // light gray for peace - const allianceColor = "#53ac75"; // green for alliance - const betrayColor = "#c74848"; // red for betray - - const iconBtn = ( - src: string, - bgColor: string, - titleKey: string, - onClick: () => void, - ) => html` - - `; - - const renderName = (p: PlayerView) => html` -
- ${p.name()} -
- `; - - const renderBtn = (btn: ReturnType) => html` -
${btn}
- `; - - const renderEmpty = () => html`
 
`; - - // Build rows for each player - const rows = players.map((p) => { - const atWar = me.isAtWarWith(p); - const allied = me.isAlliedWith(p); - const neutral = !atWar && !allied; - - // At War column cell - let atWarCell; - if (atWar) { - atWarCell = renderName(p); - } else if (neutral) { - atWarCell = renderBtn( - iconBtn( - warIcon, - warColor, - "control_panel2.diplomacy_declare_war_tooltip", - () => this.eventBus.emit(new SendDeclareWarIntentEvent(me, p)), - ), - ); - } else if (allied) { - atWarCell = renderBtn( - iconBtn( - traitorIcon, - betrayColor, - "control_panel2.diplomacy_betray_tooltip", - () => this.eventBus.emit(new SendBreakAllianceIntentEvent(me, p)), - ), - ); - } else { - atWarCell = renderEmpty(); - } - - // Allied column cell - let alliedCell; - if (allied) { - alliedCell = renderName(p); - } else { - // Can request alliance from both neutral and at-war players - alliedCell = renderBtn( - iconBtn( - allianceIcon, - allianceColor, - "control_panel2.diplomacy_request_alliance_tooltip", - () => this.eventBus.emit(new SendAllianceRequestIntentEvent(me, p)), - ), - ); - } - - // Neutral column cell - let neutralCell; - if (neutral) { - neutralCell = renderName(p); - } else if (atWar) { - neutralCell = renderBtn( - iconBtn( - peaceIcon, - peaceColor, - "control_panel2.diplomacy_request_peace_tooltip", - () => this.eventBus.emit(new SendPeaceRequestIntentEvent(me, p)), - ), - ); - } else { - neutralCell = renderEmpty(); - } - - return html` -
-
- ${atWarCell} -
-
- ${alliedCell} -
-
- ${neutralCell} -
-
- `; - }); - - // Bulk action handlers - const declareWarOnAll = () => { - players.forEach((p) => { - if (me.isAlliedWith(p)) { - // Break alliance first (betray), then declare war - this.eventBus.emit(new SendBreakAllianceIntentEvent(me, p)); - } - if (!me.isAtWarWith(p)) { - this.eventBus.emit(new SendDeclareWarIntentEvent(me, p)); - } - }); - }; - - const requestAllianceWithAll = () => { - players.forEach((p) => { - if (!me.isAlliedWith(p)) { - this.eventBus.emit(new SendAllianceRequestIntentEvent(me, p)); - } - }); + // Update cache + const tooltipText = `Trade Demand: ${queueLen} routes waiting, ${availableShips}/${myShipCount} ships available`; + this._tradeDemandCache = { + label: demandLabel, + color: demandColor, + queueLen, + availableShips, + myShipCount, + tooltip: tooltipText, + timestamp: now, }; - const requestPeaceWithAll = () => { - players.forEach((p) => { - if (me.isAtWarWith(p)) { - this.eventBus.emit(new SendPeaceRequestIntentEvent(me, p)); - } - }); - }; - - // Small icon button for header bulk actions - const headerBtn = ( - icon: string, - bgColor: string, - titleKey: string, - onClick: () => void, - ) => html` - - `; + const icon = html` + + + + + + `; return html` -
- -
-
- At War - ${headerBtn( - warIcon, - warColor, - "control_panel2.diplomacy_war_all_tooltip", - declareWarOnAll, - )} -
-
- Allied - ${headerBtn( - allianceIcon, - allianceColor, - "control_panel2.diplomacy_ally_all_tooltip", - requestAllianceWithAll, - )} -
-
- Neutral - ${headerBtn( - peaceIcon, - peaceColor, - "control_panel2.diplomacy_peace_all_tooltip", - requestPeaceWithAll, - )} -
-
- - ${rows} +
+ ${icon} + Trade Demand: + + ${demandLabel} +
`; } - private _handleEmbargoAll() { - const me = this.game.myPlayer(); - if (!me) return; - - const players = this.game - .players() - .filter( - (p) => - p.isAlive() && - p.id() !== me.id() && - (p.type() === PlayerType.Human || p.type() === PlayerType.FakeHuman), - ); - - for (const player of players) { - if (!me.hasEmbargoAgainst(player)) { - this.eventBus.emit(new SendEmbargoIntentEvent(player, "start")); - } - } - } - - private _handleRemoveAllEmbargos() { - const me = this.game.myPlayer(); - if (!me) return; - - const players = this.game - .players() - .filter( - (p) => - p.isAlive() && - p.id() !== me.id() && - (p.type() === PlayerType.Human || p.type() === PlayerType.FakeHuman), - ); - - for (const player of players) { - if (me.hasEmbargoAgainst(player)) { - this.eventBus.emit(new SendEmbargoIntentEvent(player, "stop")); - } - } - } - - private _computeTradeShipStatus(ship: UnitView): string { - // Debug ship status logging removed - const ownerName = (pv: PlayerView | null) => pv?.displayName() ?? "Unknown"; - const dockOwner = ship.dockedAtPortOwner(); - const startOwner = ship.tradeRouteStartOwner(); - const endOwner = ship.tradeRouteEndOwner(); - const targetId = ship.targetUnitId(); - const targetUnit = - targetId !== undefined ? this.game.unit(targetId) : undefined; - - if (dockOwner && !ship.returning() && targetId === undefined) { - return `in port owned by ${ownerName(dockOwner)}`; - } - - if (ship.returning()) { - if (targetUnit && targetUnit.type() === UnitType.Port) { - return `returning to port owned by ${ownerName(targetUnit.owner())}`; - } - return "returning to port"; - } - - const phase = ship.tradePhase(); - - if (phase === "toStart") { - return `traveling to start port owned by ${ownerName(startOwner)}`; - } - if (phase === "toEnd") { - if (startOwner || endOwner) { - return `trading between ${ownerName(startOwner)} and ${ownerName(endOwner)}`; - } - if (targetUnit && targetUnit.type() === UnitType.Port) { - return `traveling to port owned by ${ownerName(targetUnit.owner())}`; - } - } - - return "at sea"; + private renderDiplomacyTab() { + // Diplomacy tab removed - relations now shown via NameLayer icons + return html``; } } @@ -2891,6 +1951,116 @@ style.textContent = ` pointer-events: none; display: block; } + /* Build Tab Toolbar */ + .build-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 0; + border-bottom: 1px solid var(--ui-border-muted); + margin-bottom: 6px; + } + .toolbar-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid var(--ui-border-muted); + background: var(--ui-panel-shell-top); + color: var(--ui-text-accent); + border-radius: 4px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + } + .toolbar-btn:hover { + background: var(--ui-panel-shell-bottom); + border-color: var(--ui-secondary-hover); + } + .toolbar-btn.active { + background: var(--ui-secondary); + border-color: var(--ui-secondary-hover); + box-shadow: 0 0 6px rgba(50, 98, 155, 0.4); + } + .toolbar-icon { + width: 16px; + height: 16px; + object-fit: contain; + } + .toolbar-stack-control { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + border: 1px solid var(--ui-border-muted); + } + .stack-label { + font-size: 11px; + font-weight: 600; + color: var(--ui-text-muted); + margin-right: 2px; + } + .stack-btn { + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + background: var(--ui-panel-shell-top); + border: none; + border-radius: 3px; + color: var(--ui-text-accent); + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: background 0.15s; + } + .stack-btn:hover:not(:disabled) { + background: var(--ui-secondary); + } + .stack-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + } + .stack-value { + min-width: 28px; + text-align: center; + font-size: 12px; + font-weight: bold; + color: var(--ui-text-accent); + font-family: monospace; + } + .toolbar-spacer { + flex: 1; + } + .trade-demand-indicator { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--ui-border-muted); + border-radius: 4px; + font-size: 10px; + color: var(--ui-text-muted); + pointer-events: auto; + position: relative; + } + .trade-demand-indicator svg { + width: 14px; + height: 14px; + opacity: 0.7; + } + .trade-demand-value { + font-weight: bold; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; + transition: none; + } .embargo-btn:hover { background-color: var(--ui-secondary) !important; border-color: var(--ui-secondary) !important; diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index def3cfb59..86dec62e7 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -30,6 +30,7 @@ import { UnitIncomingUpdate, } from "../../../core/game/GameUpdates"; import { + ArtilleryOutOfRangeEvent, CancelAttackIntentEvent, CancelBoatIntentEvent, CancelParatrooperIntentEvent, @@ -175,7 +176,12 @@ export class EventsDisplay extends LitElement implements Layer { this.outgoingBoats = []; } - init() {} + init() { + // Listen for artillery out-of-range notifications + this.eventBus?.on(ArtilleryOutOfRangeEvent, (e) => + this.onArtilleryOutOfRangeEvent(e), + ); + } tick() { this.active = true; @@ -1233,6 +1239,17 @@ export class EventsDisplay extends LitElement implements Layer { `; } + private onArtilleryOutOfRangeEvent(event: ArtilleryOutOfRangeEvent) { + const keyLevel = + event.maxDistance >= 90 ? 3 : event.maxDistance >= 75 ? 2 : 1; + this.addEvent({ + description: translateText(`messages.artillery_out_of_range_${keyLevel}`), + type: MessageType.UNIT_DESTROYED, + createdAt: this.game.ticks(), + priority: 10, + }); + } + createRenderRoot() { return this; } diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 8768bfcba..92a5c3f28 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -1,3 +1,4 @@ +import doveIcon from "../../../../proprietary/images/dove.png"; import allianceIcon from "../../../../resources/images/AllianceIcon.svg"; import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg"; import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg"; @@ -8,12 +9,18 @@ import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg" import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg"; import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg"; import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg"; +import swordIconBlack from "../../../../resources/images/SwordIcon.svg"; import targetIcon from "../../../../resources/images/TargetIcon.svg"; import traitorIcon from "../../../../resources/images/TraitorIcon.svg"; import { EventBus } from "../../../core/EventBus"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; -import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game"; +import { + AllPlayers, + Cell, + nukeTypes, + PlayerType, +} from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent } from "../../InputHandler"; @@ -55,6 +62,8 @@ export class NameLayer implements Layer { private nukeWhiteIconImage: HTMLImageElement; private nukeRedIconImage: HTMLImageElement; private shieldIconImage: HTMLImageElement; + private warIconImage: HTMLImageElement; + private doveIconImage: HTMLImageElement; private container: HTMLDivElement; private firstPlace: PlayerView | null = null; private theme: Theme = this.game.config().theme(); @@ -90,6 +99,10 @@ export class NameLayer implements Layer { this.nukeRedIconImage.src = nukeRedIcon; this.shieldIconImage = new Image(); this.shieldIconImage.src = shieldIcon; + this.warIconImage = new Image(); + this.warIconImage.src = swordIconBlack; + this.doveIconImage = new Image(); + this.doveIconImage.src = doveIcon; } resizeCanvas() { @@ -424,7 +437,16 @@ export class NameLayer implements Layer { // Alliance icon const existingAlliance = iconsDiv.querySelector('[data-icon="alliance"]'); - if (myPlayer !== null && myPlayer.isAlliedWith(render.player)) { + const isSelf = myPlayer !== null && render.player === myPlayer; + const isHumanOrFakeHuman = + render.player.type() === PlayerType.Human || + render.player.type() === PlayerType.FakeHuman; + if ( + !isSelf && + isHumanOrFakeHuman && + myPlayer !== null && + myPlayer.isAlliedWith(render.player) + ) { if (!existingAlliance) { iconsDiv.appendChild( this.createIconElement( @@ -438,6 +460,41 @@ export class NameLayer implements Layer { existingAlliance.remove(); } + // War icon + const existingWar = iconsDiv.querySelector('[data-icon="war"]'); + if ( + !isSelf && + isHumanOrFakeHuman && + myPlayer !== null && + myPlayer.isAtWarWith(render.player) + ) { + if (!existingWar) { + iconsDiv.appendChild( + this.createIconElement(this.warIconImage.src, iconSize, "war"), + ); + } + } else if (existingWar) { + existingWar.remove(); + } + + // Neutral icon + const existingNeutral = iconsDiv.querySelector('[data-icon="neutral"]'); + if ( + !isSelf && + isHumanOrFakeHuman && + myPlayer !== null && + !myPlayer.isAlliedWith(render.player) && + !myPlayer.isAtWarWith(render.player) + ) { + if (!existingNeutral) { + iconsDiv.appendChild( + this.createIconElement(this.doveIconImage.src, iconSize, "neutral"), + ); + } + } else if (existingNeutral) { + existingNeutral.remove(); + } + // Alliance request icon let existingRequestAlliance = iconsDiv.querySelector( '[data-icon="alliance-request"]', @@ -584,12 +641,6 @@ export class NameLayer implements Layer { iconsDiv.appendChild(this.createIconElement(icon, iconSize, "nuke")); } } - // Update all icon sizes - const icons = iconsDiv.getElementsByTagName("img"); - for (const icon of icons) { - icon.style.width = `${iconSize}px`; - icon.style.height = `${iconSize}px`; - } // Position element with scale if (render.location && render.location !== oldLocation) { @@ -606,10 +657,29 @@ export class NameLayer implements Layer { ): HTMLImageElement { const icon = document.createElement("img"); icon.src = src; - icon.style.width = `${size}px`; - icon.style.height = `${size}px`; + + // Make war icon 20% smaller + const actualSize = id === "war" ? size * 0.8 : size; + icon.style.width = `${actualSize}px`; + icon.style.height = `${actualSize}px`; icon.setAttribute("data-icon", id); icon.setAttribute("dark-mode", this.userSettings.darkMode().toString()); + + if (id === "war") { + // Use CSS mask with exact warColor #8B0000 from radial menu + icon.style.backgroundColor = "#8B0000"; + icon.style.webkitMaskImage = `url(${src})`; + icon.style.maskImage = `url(${src})`; + icon.style.webkitMaskSize = "contain"; + icon.style.maskSize = "contain"; + icon.style.webkitMaskRepeat = "no-repeat"; + icon.style.maskRepeat = "no-repeat"; + icon.style.webkitMaskPosition = "center"; + icon.style.maskPosition = "center"; + // Clear the src to prevent img from loading + icon.removeAttribute("src"); + } + if (center) { icon.style.position = "absolute"; icon.style.top = "50%"; diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 6f939f9dc..6a0ec08c1 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -178,11 +178,24 @@ export class PlayerInfoOverlay extends LitElement implements Layer { let displayRelation = false; let relationClass = ""; let relationName = ""; + // Icons are not shown in overlay; text only if (myPlayer.isFriendly(player)) { relationClass = this.getRelationClass(Relation.Friendly); relationName = translateText("relation.allied"); displayRelation = true; + } else if (myPlayer.isAtWarWith(player)) { + relationClass = "text-red-500"; + relationName = translateText("relation.hostile"); + displayRelation = true; + } else if ( + !myPlayer.isAlliedWith(player) && + !myPlayer.isAtWarWith(player) + ) { + // Neutral + relationClass = "text-yellow-300"; + relationName = translateText("relation.neutral"); + displayRelation = true; } else if (player.type() === PlayerType.FakeHuman) { const relation = this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral; @@ -219,6 +232,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { UnitType.Factory, UnitType.Port, UnitType.Warship, + UnitType.Artillery, UnitType.MissileSilo, UnitType.SAMLauncher, UnitType.Airfield, @@ -234,6 +248,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { [UnitType.Factory]: "/images/factoryicon.png", [UnitType.Port]: "/images/PortIcon.svg", [UnitType.Warship]: "/images/BattleshipIconWhite.svg", + [UnitType.Artillery]: "/images/artillery-battery.png", [UnitType.MissileSilo]: "/images/MissileSiloIconWhite.svg", [UnitType.SAMLauncher]: "/images/SamLauncherIconWhite.svg", [UnitType.Airfield]: "/images/AirfieldIcon.svg", @@ -342,20 +357,23 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
-
+
${unitTypes.map((unitType) => { const iconSrc = unitIconMap[unitType]; if (!iconSrc) return null; - // Use unitsOwned for upgraded structures (City, Port, Hospital, Academy) - // so counts reflect summed levels + constructions, consistent with server. + // Use unitsOwned for all stackable structures + // so counts reflect summed stack counts + constructions, consistent with server. const count = unitType === UnitType.City || unitType === UnitType.Port || unitType === UnitType.Hospital || unitType === UnitType.Academy || unitType === UnitType.ResearchLab || - unitType === UnitType.Factory + unitType === UnitType.Factory || + unitType === UnitType.SAMLauncher || + unitType === UnitType.Airfield || + unitType === UnitType.MissileSilo ? player.unitsOwned(unitType) : player.units(unitType).length; diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 5c21d0952..37bb1f3db 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -2,9 +2,9 @@ import * as d3 from "d3"; import doveIcon from "../../../../proprietary/images/dove.png"; import warIcon from "../../../../proprietary/images/waricon.png"; import airAttackIcon from "../../../../resources/images/AirAttackIconWhite.svg"; +import airfieldIcon from "../../../../resources/images/AirfieldIcon.svg"; import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; -import disabledIcon from "../../../../resources/images/DisabledIcon.svg"; import infoIcon from "../../../../resources/images/InfoIcon.svg"; import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; @@ -28,6 +28,7 @@ import { SendAllianceRequestIntentEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, + SendBomberIntentEvent, SendBreakAllianceIntentEvent, SendDeclareWarIntentEvent, SendParatrooperAttackIntentEvent, @@ -47,6 +48,7 @@ enum Slot { Ally, Peace, AirAttack, + Bomber, } export class RadialMenu implements Layer { @@ -74,7 +76,7 @@ export class RadialMenu implements Layer { disabled: true, action: () => {}, color: null, - icon: null, + icon: boatIcon, }, ], [ @@ -84,7 +86,17 @@ export class RadialMenu implements Layer { disabled: true, action: () => {}, color: null, - icon: null, + icon: airAttackIcon, + }, + ], + [ + Slot.Bomber, + { + name: "bomber", + disabled: true, + action: () => {}, + color: null, + icon: airfieldIcon, }, ], [ @@ -94,10 +106,19 @@ export class RadialMenu implements Layer { disabled: true, action: () => {}, color: null, - icon: null, + icon: infoIcon, + }, + ], + [ + Slot.Ally, + { + name: "ally", + disabled: true, + action: () => {}, + color: null, + icon: allianceIcon, }, ], - [Slot.Ally, { name: "ally", disabled: true, action: () => {} }], [ Slot.Peace, { @@ -116,7 +137,14 @@ export class RadialMenu implements Layer { private readonly centerButtonSize = 30; private readonly iconSize = 32; private readonly centerIconSize = 48; - private readonly disabledColor = d3.rgb(128, 128, 128).toString(); + // When disabled, keep the hue but darken it slightly so the icon stays visible. + private darkenColor(color: string): string { + const parsed = d3.color(color); + if (!parsed) return d3.rgb(80, 80, 80).toString(); + // darker(0.6) keeps the original hue while making it visibly inactive + const darker = (parsed as any).darker?.(0.6); + return (darker ?? parsed).toString(); + } // Scale factor specifically for the Peace (dove) icon relative to iconSize private readonly peaceIconScale = 1.2; @@ -189,12 +217,14 @@ export class RadialMenu implements Layer { .append("path") .attr("d", arc) .attr("fill", (d) => - d.data.disabled ? this.disabledColor : d.data.color, + d.data.disabled + ? this.darkenColor(d.data.color ?? "#444") + : d.data.color, ) .attr("stroke", "#ffffff") .attr("stroke-width", "2") .style("cursor", (d) => (d.data.disabled ? "not-allowed" : "pointer")) - .style("opacity", (d) => (d.data.disabled ? 0.5 : 1)) + .style("opacity", (d) => (d.data.disabled ? 0.7 : 1)) .attr("data-name", (d) => d.data.name) .on("mouseover", function (event, d) { if (!d.data.disabled) { @@ -476,13 +506,72 @@ export class RadialMenu implements Layer { }); } + if (this.shouldShowBomber(myPlayer, tile)) { + this.activateMenuElement(Slot.Bomber, "#FF6B35", airfieldIcon, () => { + if (this.clickedCell === null) return; + const targetPlayer = this.g.owner(tile) as PlayerView; + // Target all structure types with closest-first priority + const allStructures = [ + UnitType.City, + UnitType.DefensePost, + UnitType.SAMLauncher, + UnitType.MissileSilo, + UnitType.Port, + UnitType.Airfield, + UnitType.Hospital, + UnitType.Academy, + UnitType.ResearchLab, + UnitType.Factory, + UnitType.DoomsdayDevice, + ]; + this.eventBus.emit( + new SendBomberIntentEvent(targetPlayer.id(), allStructures, true), + ); + }); + } + if (!this.g.hasOwner(tile)) { return; } } + private shouldShowBomber(player: PlayerView, tile: TileRef): boolean { + // Check if player has at least one active airfield + if (player.units(UnitType.Airfield).length === 0) { + return false; + } + // Check if tile is land + if (!this.g.isLand(tile)) { + return false; + } + // Check if tile is owned by an enemy + const owner = this.g.owner(tile); + if (owner === player || !owner.isPlayer()) { + return false; + } + // Check if player is at war with owner + if (!player.isAtWarWith?.(owner as PlayerView)) { + return false; + } + // Check if any airfield can reach this tile + const airfields = player.units(UnitType.Airfield); + for (const airfield of airfields) { + if (!airfield.isActive()) continue; + const range = this.g + .config() + .bomberTargetRange(airfield.bomberLevel?.() ?? 1); + const dist = Math.sqrt( + this.g.euclideanDistSquared(airfield.tile(), tile), + ); + if (dist <= range) { + return true; + } + } + return false; + } + private shouldShowAirAttack(player: PlayerView, tile: TileRef): boolean { - if (!player.hasUpgrade(UpgradeType.AirUpgrade1)) { + if (!player.hasUpgrade(UpgradeType.JetEngines)) { return false; } if (player.units(UnitType.Airfield).length === 0) { @@ -616,14 +705,17 @@ export class RadialMenu implements Layer { private updateMenuItemState(item: any) { const menuItem = this.menuElement.select(`path[data-name="${item.name}"]`); menuItem - .attr("fill", item.disabled ? this.disabledColor : item.color) + .attr( + "fill", + item.disabled ? this.darkenColor(item.color ?? "#444") : item.color, + ) .style("cursor", item.disabled ? "not-allowed" : "pointer") - .style("opacity", item.disabled ? 0.5 : 1); + .style("opacity", item.disabled ? 0.7 : 1); this.menuElement .select(`image[data-name="${item.name}"]`) - .attr("xlink:href", item.disabled ? disabledIcon : item.icon) - .attr("fill", item.disabled ? "#999999" : "white"); + .attr("xlink:href", item.icon) + .style("opacity", item.disabled ? 0.7 : 1); } private onCenterButtonHover(isHovering: boolean) { diff --git a/src/client/graphics/layers/RangeOverlayLayer.ts b/src/client/graphics/layers/RangeOverlayLayer.ts index 246422be8..6172bb2a8 100644 --- a/src/client/graphics/layers/RangeOverlayLayer.ts +++ b/src/client/graphics/layers/RangeOverlayLayer.ts @@ -3,6 +3,7 @@ import { EventBus } from "../../../core/EventBus"; import { Cell, UnitType } from "../../../core/game/Game"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; +import { playerMaxStructureTechLevel } from "../../../core/game/Upgradeables"; import { MouseOverEvent } from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; import { UIState } from "../UIState"; @@ -404,7 +405,8 @@ export class RangeOverlayLayer implements Layer { if (u.type() === UnitType.SAMLauncher) { const base = this.game.config().defaultSamRange(); const bonus = this.game.config().samRangeUpgradePercent(); - const lvl = u.level(); + // Use player's SAM tech level, not unit level (which is stack count) + const lvl = playerMaxStructureTechLevel(u.owner(), UnitType.SAMLauncher); if (lvl <= 1) return base; const factor = Math.pow(1 + bonus, lvl - 1); return Math.round(base * factor); diff --git a/src/client/graphics/layers/ResearchToggleButton.ts b/src/client/graphics/layers/ResearchToggleButton.ts index 5d751e34e..b1ae56d3b 100644 --- a/src/client/graphics/layers/ResearchToggleButton.ts +++ b/src/client/graphics/layers/ResearchToggleButton.ts @@ -19,9 +19,6 @@ export class ResearchToggleButton extends LitElement implements Layer { @state() private _isModalOpen = false; - @state() - private _hasUnseenPolicyDirectives = false; - private modalRef: ResearchTreeModal | null = null; createRenderRoot() { @@ -42,12 +39,6 @@ export class ResearchToggleButton extends LitElement implements Layer { this._isVisible = shouldShow; this.requestUpdate(); } - // Check for unseen policy directives - const hasUnseen = player?.hasUnseenPolicyDirectives?.() ?? false; - if (hasUnseen !== this._hasUnseenPolicyDirectives) { - this._hasUnseenPolicyDirectives = hasUnseen; - this.requestUpdate(); - } this.updateModalState(); } @@ -188,9 +179,6 @@ export class ResearchToggleButton extends LitElement implements Layer { @click=${this.toggleModal} style="position: relative;" > - ${this._hasUnseenPolicyDirectives - ? html`!` - : ""} ${["R", "E", "S", "E", "A", "R", "C", "H"].map( (letter) => html`${letter}`, )} diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 00abf74b5..5632f99e3 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -22,6 +22,7 @@ import { getUnitUpgradeCost } from "../../../core/game/UnitUpgrades"; import { isUpgradeableStructure, playerMaxStructureLevel, + playerMaxStructureTechLevel, playerMaxUnitLevel, } from "../../../core/game/Upgradeables"; import { ToggleBomberUpgradeModeEvent } from "../../events/ToggleBomberUpgradeModeEvent"; @@ -564,16 +565,16 @@ export class StructureLayer implements Layer { this.ensureStructureLevels(unit); const record = this.structureLevels.get(unit.id()); if (record) { - // Sync primary level from server value. + // Sync primary from server stack count. const prevLevel = record.primary; - const serverLevel = unit.level(); - record.primary = serverLevel; - // If the hovered structure's level changed, refresh the label immediately. + const serverStackCount = unit.stackCount?.() ?? 1; + record.primary = serverStackCount; + // If the hovered structure's stack count changed, refresh the label immediately. if (this.hoveredStructure && this.hoveredStructure.id() === unit.id()) { this.updateLabels(); } - // If level changed and we're in upgrade mode, re-render texture so highlight state updates - if (prevLevel !== serverLevel && this.upgradeMode) { + // If stack count changed and we're in upgrade mode, re-render texture so highlight state updates + if (prevLevel !== serverStackCount && this.upgradeMode) { // Refresh texture so highlight state updates based on new level const target = this.renders.find((r) => r.unit.id() === unit.id()); if (target) { @@ -1043,18 +1044,37 @@ export class StructureLayer implements Layer { !this.structureLevels.has(id) && unit.type() !== UnitType.Construction ) { - // Initialize with server level (typically 1 unless upgraded before client joined) + // Initialize with stack count (for display) instead of level // For airfields, set secondary to bomber upgrade level - const secondary = - unit.type() === UnitType.Airfield ? unit.bomberLevel() : 0; - this.structureLevels.set(id, { primary: unit.level(), secondary }); + // For SAMs, set secondary to SAM tech level + let secondary = 0; + if (unit.type() === UnitType.Airfield) { + secondary = unit.bomberLevel(); + } else if (unit.type() === UnitType.SAMLauncher) { + const player = this.game.myPlayer(); + if (player && unit.owner().id() === player.id()) { + secondary = playerMaxStructureTechLevel(player, UnitType.SAMLauncher); + } + } + this.structureLevels.set(id, { + primary: unit.stackCount?.() ?? 1, + secondary, + }); } else if (this.structureLevels.has(id)) { - // Keep in sync with authoritative server level each tick/render cycle + // Keep in sync with authoritative server stack count each tick/render cycle const rec = this.structureLevels.get(id)!; - rec.primary = unit.level(); + rec.primary = unit.stackCount?.() ?? 1; // For airfields, update secondary to bomber upgrade level if (unit.type() === UnitType.Airfield) { rec.secondary = unit.bomberLevel(); + } else if (unit.type() === UnitType.SAMLauncher) { + const player = this.game.myPlayer(); + if (player && unit.owner().id() === player.id()) { + rec.secondary = playerMaxStructureTechLevel( + player, + UnitType.SAMLauncher, + ); + } } } } diff --git a/src/client/graphics/layers/TechUnlockNotification.ts b/src/client/graphics/layers/TechUnlockNotification.ts index 95aaf5dcc..0c1ed838f 100644 --- a/src/client/graphics/layers/TechUnlockNotification.ts +++ b/src/client/graphics/layers/TechUnlockNotification.ts @@ -87,10 +87,11 @@ export class TechUnlockNotification extends LitElement implements Layer { const meta = getTechMeta(techId, { strict: false }); if (!meta) continue; this.seenTechs.add(techId); + const body = meta.shortDescription ?? meta.description ?? ""; this.enqueue({ id: techId, name: meta.name ?? techId, - description: meta.description ?? "", + description: body, }); } for (const techId of filtered) this.seenTechs.add(techId); diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 94e3a01f9..9d8347826 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -412,28 +412,24 @@ export class TerritoryLayer implements Layer { // Only call putImageData if something actually changed if (this.isDirty && this.dirtyRect) { - const [topLeft, bottomRight] = - this.transformHandler.screenBoundingRect(); - // Intersect dirty rect with visible viewport - const vx0 = Math.max(0, topLeft.x, this.dirtyRect.x0); - const vy0 = Math.max(0, topLeft.y, this.dirtyRect.y0); - const vx1 = Math.min(this._width - 1, bottomRight.x, this.dirtyRect.x1); - const vy1 = Math.min( - this._height - 1, - bottomRight.y, - this.dirtyRect.y1, - ); + // Apply the dirty rect directly without viewport clipping + // The canvas needs to stay in sync with ImageData even for off-screen areas + // so that when the user zooms out, those areas are already rendered + const x0 = Math.max(0, this.dirtyRect.x0); + const y0 = Math.max(0, this.dirtyRect.y0); + const x1 = Math.min(this._width - 1, this.dirtyRect.x1); + const y1 = Math.min(this._height - 1, this.dirtyRect.y1); - const w = vx1 - vx0 + 1; - const h = vy1 - vy0 + 1; + const w = x1 - x0 + 1; + const h = y1 - y0 + 1; if (w > 0 && h > 0) { this.context.putImageData( this.alternativeView ? this.alternativeImageData : this.imageData, 0, 0, - vx0, - vy0, + x0, + y0, w, h, ); diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index b1c8fc177..40cdf5a4e 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -70,7 +70,8 @@ export class UILayer implements Layer { this.selectedUnit && (this.selectedUnit.type() === UnitType.Warship || this.selectedUnit.type() === UnitType.FighterJet || - this.selectedUnit.type() === UnitType.Submarine) + this.selectedUnit.type() === UnitType.Submarine || + this.selectedUnit.type() === UnitType.Artillery) ) { this.drawSelectionBox(this.selectedUnit); } @@ -195,7 +196,8 @@ export class UILayer implements Layer { event.unit && (event.unit.type() === UnitType.Warship || event.unit.type() === UnitType.FighterJet || - event.unit.type() === UnitType.Submarine) + event.unit.type() === UnitType.Submarine || + event.unit.type() === UnitType.Artillery) ) { this.drawSelectionBox(event.unit); } diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 0a539192f..c8bbe05ee 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -13,6 +13,8 @@ import { UnitSelectionEvent, } from "../../InputHandler"; import { + ArtilleryOutOfRangeEvent, + MoveArtilleryIntentEvent, MoveFighterJetIntentEvent, MoveSubmarineIntentEvent, MoveWarshipIntentEvent, @@ -25,6 +27,7 @@ import type { UIState } from "../UIState"; import type { Layer } from "./Layer"; import { GameUpdateType } from "../../../core/game/GameUpdates"; +import { getArtilleryMaxDistance } from "../../../core/game/UnitUpgrades"; import { getColoredSprite, isSpriteReady, @@ -44,6 +47,7 @@ const UNIT_LAYER_TYPES = new Set([ UnitType.Paratrooper, UnitType.Submarine, UnitType.Warship, + // Artillery is rendered by ArtilleryLayer UnitType.Shell, UnitType.SAMMissile, UnitType.TradeShip, @@ -255,6 +259,28 @@ export class UnitLayer implements Layer { }); } + private findArtilleryNearCell(cell: { x: number; y: number }): UnitView[] { + if (!this.game.isValidCoord(cell.x, cell.y)) { + return []; + } + const clickRef = this.game.ref(cell.x, cell.y); + + return this.game + .units(UnitType.Artillery) + .filter( + (unit) => + unit.isActive() && + unit.owner() === this.game.myPlayer() && + this.game.manhattanDist(unit.tile(), clickRef) <= + this.WARSHIP_SELECTION_RADIUS, // Reuse warship radius for artillery + ) + .sort((a, b) => { + const distA = this.game.manhattanDist(a.tile(), clickRef); + const distB = this.game.manhattanDist(b.tile(), clickRef); + return distA - distB; + }); + } + private onMouseUp(event: MouseUpEvent) { // Convert screen coordinates to world coordinates const cell = this.transformHandler.screenToWorldCoordinates( @@ -266,6 +292,7 @@ export class UnitLayer implements Layer { const nearbyWarships = this.findWarshipsNearCell(cell); const nearbySubmarines = this.findSubmarinesNearCell(cell); const nearbyFighterJets = this.findFighterJetsNearCell(cell); + const nearbyArtillery = this.findArtilleryNearCell(cell); // unit upgrade mode removed: proceed with selection/move logic only @@ -289,6 +316,24 @@ export class UnitLayer implements Layer { this.eventBus.emit( new MoveSubmarineIntentEvent(this.selectedUnit.id(), clickRef), ); + } else if ( + this.selectedUnit.type() === UnitType.Artillery && + !this.game.isOcean(clickRef) + ) { + // Check distance cap before sending move intent + const lvl = this.selectedUnit.level ? this.selectedUnit.level() : 1; + const maxDist = getArtilleryMaxDistance(lvl); + const distSq = this.game.euclideanDistSquared( + this.selectedUnit.tile(), + clickRef, + ); + if (distSq > maxDist * maxDist) { + this.eventBus.emit(new ArtilleryOutOfRangeEvent(lvl, maxDist)); + } else { + this.eventBus.emit( + new MoveArtilleryIntentEvent(this.selectedUnit.id(), clickRef), + ); + } } // Mark click as consumed whenever a unit was selected, so other handlers don't also treat it as an attack event.consumed = true; @@ -306,6 +351,9 @@ export class UnitLayer implements Layer { } else if (nearbyFighterJets.length > 0) { const clickedUnit = nearbyFighterJets[0]; this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true)); + } else if (nearbyArtillery.length > 0) { + const clickedUnit = nearbyArtillery[0]; + this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true)); } } @@ -789,6 +837,9 @@ export class UnitLayer implements Layer { case UnitType.Warship: this.handleWarShipEvent(unit, angleByUnit); break; + case UnitType.Artillery: + this.handleArtilleryEvent(unit, angleByUnit); + break; case UnitType.Shell: this.handleShellEvent(unit); break; @@ -830,6 +881,13 @@ export class UnitLayer implements Layer { } } + private handleArtilleryEvent( + unit: UnitView, + angleByUnit?: Map, + ) { + // Artillery is now rendered by StructureLayer (GPU-accelerated PIXI) + } + private handleShellEvent(unit: UnitView) { const rel = this.relationship(unit); diff --git a/src/client/stats/StatDefinitions.ts b/src/client/stats/StatDefinitions.ts index f03b6a4ed..c1fe48a27 100644 --- a/src/client/stats/StatDefinitions.ts +++ b/src/client/stats/StatDefinitions.ts @@ -184,14 +184,12 @@ export function computeStatValue( case "Land Techs": case "Sea Techs": case "Air Techs": - case "Nuclear Techs": - case "Economy Techs": { + case "Nuclear Techs": { const labelToCat: Record = { "Land Techs": "Land", "Sea Techs": "Sea", "Air Techs": "Air", "Nuclear Techs": "Nuclear", - "Economy Techs": "Economy", }; const cat = labelToCat[label]; const nodes = getTechNodes(); diff --git a/src/client/styles.css b/src/client/styles.css index 5ce775705..690058e83 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -605,6 +605,12 @@ label.option-card:hover { cover; } +#helpModal .artillery-icon { + mask: url("../../proprietary/images/artillery-battery.png") no-repeat center / + cover; + background-size: contain; +} + @media screen and (max-width: 768px) { #helpModal .modal-content { max-height: 90vh; diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts index c244453de..3391ea18b 100644 --- a/src/client/utilities/RenderUnitTypeOptions.ts +++ b/src/client/utilities/RenderUnitTypeOptions.ts @@ -14,6 +14,7 @@ const unitOptions: { type: UnitType; translationKey: string }[] = [ { type: UnitType.Port, translationKey: "unit_type.port" }, { type: UnitType.Airfield, translationKey: "unit_type.airfield" }, { type: UnitType.Warship, translationKey: "unit_type.warship" }, + { type: UnitType.Artillery, translationKey: "unit_type.artillery" }, { type: UnitType.FighterJet, translationKey: "unit_type.fighter_jet" }, { type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" }, { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" }, @@ -38,7 +39,7 @@ export function renderUnitTypeOptions({ ({ type, translationKey }) => html`