diff --git a/resources/lang/en.json b/resources/lang/en.json index 8a57a4cf2d..4920741b9f 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -699,7 +699,89 @@ "user_setting": { "title": "Settings", "tab_basic": "Basic Settings", + "tab_sounds": "Sounds", + "tab_effects": "Effects", + "tab_notifications": "Notifications", "tab_keybinds": "Keybinds", + "notifications_game_start_label": "Game start notification", + "notifications_game_start_desc": "Show a browser notification when a game starts", + "notifications_permission_label": "Browser permission", + "notifications_permission_granted": "Granted", + "notifications_permission_denied": "Denied", + "notifications_permission_default": "Not granted", + "notifications_permission_unsupported": "Not supported", + "notifications_permission_request": "Request permission", + "sounds_background_music_label": "Background Music Volume", + "sounds_background_music_desc": "Adjust the volume of the background music", + "sounds_effects_label": "Sound Effects Volume", + "sounds_effects_desc": "Adjust the volume of in-game sound effects", + "sounds_category_volume": "Volume", + "sounds_category_weapons": "Weapons & Combat", + "sounds_category_diplomacy": "Diplomacy", + "sounds_category_construction": "Construction", + "sounds_category_ui": "UI & Notifications", + "sounds_effect_atom_launch_label": "Atomic Bomb Launch", + "sounds_effect_atom_launch_desc": "Played when an atomic bomb is launched", + "sounds_effect_atom_hit_label": "Atomic Bomb Hit", + "sounds_effect_atom_hit_desc": "Played when an atomic bomb hits a target", + "sounds_effect_hydrogen_launch_label": "Hydrogen Bomb Launch", + "sounds_effect_hydrogen_launch_desc": "Played when a hydrogen bomb is launched", + "sounds_effect_hydrogen_hit_label": "Hydrogen Bomb Hit", + "sounds_effect_hydrogen_hit_desc": "Played when a hydrogen bomb hits a target", + "sounds_effect_mirv_launch_label": "MIRV Launch", + "sounds_effect_mirv_launch_desc": "Played when a MIRV missile is launched", + "sounds_effect_alliance_suggested_label": "Alliance Suggested", + "sounds_effect_alliance_suggested_desc": "Played when another player proposes an alliance", + "sounds_effect_alliance_broken_label": "Alliance Broken", + "sounds_effect_alliance_broken_desc": "Played when an alliance is broken", + "sounds_effect_build_city_label": "City Built", + "sounds_effect_build_city_desc": "Played when a city is constructed", + "sounds_effect_build_port_label": "Port Built", + "sounds_effect_build_port_desc": "Played when a port is constructed", + "sounds_effect_build_defense_post_label": "Defense Post Built", + "sounds_effect_build_defense_post_desc": "Played when a defense post is constructed", + "sounds_effect_build_warship_label": "Warship Built", + "sounds_effect_build_warship_desc": "Played when a warship is constructed", + "sounds_effect_sam_built_label": "SAM Site Built", + "sounds_effect_sam_built_desc": "Played when a SAM site is constructed", + "sounds_effect_upgrade_label": "Structure Upgraded", + "sounds_effect_upgrade_desc": "Played when a building, port or factory is upgraded", + "sounds_effect_add_ammo_label": "Ammo Added", + "sounds_effect_add_ammo_desc": "Played when a missile silo or SAM is upgraded", + "fx_category_nuclear": "Nuclear", + "fx_category_combat": "Combat", + "fx_category_environment": "Environment", + "fx_category_alerts": "Screen Alerts", + "fx_nuke_telegraph_label": "Nuke Telegraph", + "fx_nuke_telegraph_desc": "Trajectory line shown when a nuke is launched by you or a teammate", + "fx_alert_land_attack_label": "Attack Alert", + "fx_alert_land_attack_desc": "Orange border flash when you are being attacked", + "fx_alert_betrayal_label": "Betrayal Alert", + "fx_alert_betrayal_desc": "Red border flash when an ally breaks your alliance", + "fx_nuke_explosion_label": "Nuclear Explosion", + "fx_nuke_explosion_desc": "Main explosion animation and shockwave when a nuke detonates", + "fx_nuke_debris_label": "Nuclear Debris", + "fx_nuke_debris_desc": "Fire and smoke debris scattered around the nuke impact zone", + "fx_sam_interception_label": "SAM Interception", + "fx_sam_interception_desc": "Explosion and shockwave when a SAM intercepts a missile", + "fx_building_explosion_label": "Building Explosion", + "fx_building_explosion_desc": "Explosion animation when a building is destroyed", + "fx_shell_impact_label": "Shell Impact", + "fx_shell_impact_desc": "Small explosion when a shell or train reaches its target", + "fx_warship_sinking_label": "Warship Sinking", + "fx_warship_sinking_desc": "Explosion and sinking animation when a warship is destroyed", + "fx_conquest_label": "Conquest Sword", + "fx_conquest_desc": "Sword animation displayed when you conquer enemy territory", + "fx_dust_label": "Railroad Dust", + "fx_dust_desc": "Dust particles when railroad tiles are destroyed", + "sounds_effect_ka_ching_label": "Gold Received", + "sounds_effect_ka_ching_desc": "Played for gold or economy events", + "sounds_effect_message_label": "Message", + "sounds_effect_message_desc": "Played when a chat message is received", + "sounds_effect_click_label": "Click", + "sounds_effect_click_desc": "Played on UI button clicks", + "sounds_effect_game_start_label": "Game Start", + "sounds_effect_game_start_desc": "Played when a game begins", "keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.", "dark_mode_label": "Dark Mode", "dark_mode_desc": "Toggle the site’s appearance between light and dark themes", @@ -1270,5 +1352,9 @@ "fullscreen": { "enter": "Enter fullscreen", "exit": "Exit fullscreen" + }, + "game_start_notification": { + "title": "The game is starting", + "body": "Choose your starting position" } } diff --git a/resources/sounds/effects/add-ammo.mp3 b/resources/sounds/effects/add-ammo.mp3 new file mode 100644 index 0000000000..5cad934b87 Binary files /dev/null and b/resources/sounds/effects/add-ammo.mp3 differ diff --git a/resources/sounds/effects/start.mp3 b/resources/sounds/effects/start.mp3 new file mode 100644 index 0000000000..3388e20173 Binary files /dev/null and b/resources/sounds/effects/start.mp3 differ diff --git a/resources/sounds/effects/upgrade.mp3 b/resources/sounds/effects/upgrade.mp3 new file mode 100644 index 0000000000..4d18719dd9 Binary files /dev/null and b/resources/sounds/effects/upgrade.mp3 differ diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 5cb2ad853b..6064ecb63a 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -61,6 +61,7 @@ import { createCanvas } from "./Utils"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; import { GoToPlayerEvent } from "./graphics/TransformHandler"; import { SoundManager } from "./sound/SoundManager"; +import { PlaySoundEffectEvent } from "./sound/Sounds"; export interface LobbyConfig { cosmetics: PlayerCosmeticRefs; @@ -99,6 +100,7 @@ export function joinLobby( startGame(lobbyConfig.gameID, lobbyConfig.gameStartInfo?.config ?? {}); const transport = new Transport(lobbyConfig, eventBus); + const soundManager = new SoundManager(eventBus, userSettings); let currentGameRunner: ClientGameRunner | null = null; @@ -130,6 +132,23 @@ export function joinLobby( if (message.type === "start") { // Trigger prestart for singleplayer games resolvePrestart(); + + eventBus.emit(new PlaySoundEffectEvent("game-start")); + + if ( + "Notification" in window && + Notification.permission === "granted" && + document.hidden && + userSettings.gameStartNotificationsEnabled() + ) { + try { + new Notification(translateText("game_start_notification.title"), { + body: translateText("game_start_notification.body"), + }); + } catch (err) { + console.warn("Failed to show game start notification:", err); + } + } console.log( `lobby: game started: ${JSON.stringify(message, replacer, 2)}`, ); @@ -144,6 +163,7 @@ export function joinLobby( eventBus, transport, userSettings, + soundManager, terrainLoad, terrainMapFileLoader, ) @@ -231,12 +251,14 @@ async function createClientGame( eventBus: EventBus, transport: Transport, userSettings: UserSettings, + soundManager: SoundManager, terrainLoad: Promise | null, mapLoader: GameMapLoader, ): Promise { if (lobbyConfig.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); } + const config = new Config( lobbyConfig.gameStartInfo.config, userSettings, @@ -267,7 +289,6 @@ async function createClientGame( ); const canvas = createCanvas(); - const soundManager = new SoundManager(eventBus, userSettings); try { const gameRenderer = createRenderer( canvas, diff --git a/src/client/Main.ts b/src/client/Main.ts index bd73b5f46c..bd58557f41 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -818,6 +818,16 @@ class Client { return; } + try { + if ("Notification" in window && Notification.permission === "default") { + Notification.requestPermission().catch(() => { + // Ignore permission request errors + }); + } + } catch (err) { + console.warn("Failed to request notification permission:", err); + } + console.log(`joining lobby ${lobby.gameID}`); if (this.lobbyHandle !== null) { console.log("joining lobby, stopping existing game"); diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index e36444ee79..db13d4415d 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -214,6 +214,7 @@ export class UserSettingModal extends BaseModal { private toggleAlertFrame() { this.userSettings.toggleAlertFrame(); + this.requestUpdate(); console.log( "🚨 Alert frame:", @@ -223,6 +224,7 @@ export class UserSettingModal extends BaseModal { private toggleFxLayer() { this.userSettings.toggleFxLayer(); + this.requestUpdate(); console.log( "💥 Special effects:", @@ -325,6 +327,12 @@ export class UserSettingModal extends BaseModal { return { tabs: [ { key: "basic", label: translateText("user_setting.tab_basic") }, + { key: "sounds", label: translateText("user_setting.tab_sounds") }, + { key: "effects", label: translateText("user_setting.tab_effects") }, + { + key: "notifications", + label: translateText("user_setting.tab_notifications"), + }, { key: "keybinds", label: translateText("user_setting.tab_keybinds") }, ], }; @@ -340,10 +348,18 @@ export class UserSettingModal extends BaseModal { } protected renderBody(tab: string) { - const body = - tab === "keybinds" - ? this.renderKeybindSettings() - : this.renderBasicSettings(); + let body; + if (tab === "keybinds") { + body = this.renderKeybindSettings(); + } else if (tab === "sounds") { + body = this.renderSoundSettings(); + } else if (tab === "effects") { + body = this.renderEffectSettings(); + } else if (tab === "notifications") { + body = this.renderNotificationSettings(); + } else { + body = this.renderBasicSettings(); + } return html`
${body}
`; @@ -353,6 +369,455 @@ export class UserSettingModal extends BaseModal { window.removeEventListener("keydown", this.handleEasterEggKey); } + private sliderBackgroundMusicVolume(e: CustomEvent<{ value: number }>) { + const value = e.detail?.value; + if (typeof value === "number") { + this.userSettings.setBackgroundMusicVolume(value / 100); + this.requestUpdate(); + } + } + + private sliderSoundEffectsVolume(e: CustomEvent<{ value: number }>) { + const value = e.detail?.value; + if (typeof value === "number") { + this.userSettings.setSoundEffectsVolume(value / 100); + this.requestUpdate(); + } + } + + private async requestNotificationPermission() { + if ("Notification" in window && Notification.permission === "default") { + await Notification.requestPermission(); + this.requestUpdate(); + } + } + + private renderNotificationSettings() { + const supported = "Notification" in window; + const permission = supported ? Notification.permission : "unsupported"; + + const permissionLabel = () => { + if (!supported) + return translateText( + "user_setting.notifications_permission_unsupported", + ); + if (permission === "granted") + return translateText("user_setting.notifications_permission_granted"); + if (permission === "denied") + return translateText("user_setting.notifications_permission_denied"); + return translateText("user_setting.notifications_permission_default"); + }; + + return html` + { + this.userSettings.toggleGameStartNotifications(); + this.requestUpdate(); + }} + > + + ${supported + ? html` +
+
+ + ${translateText( + "user_setting.notifications_permission_label", + )} + + + ${permissionLabel()} + +
+ ${permission === "default" + ? html` + + ` + : null} +
+ ` + : null} + `; + } + + private toggleSoundEffect(effect: string) { + this.userSettings.setSoundEffectEnabled( + effect, + !this.userSettings.isSoundEffectEnabled(effect), + ); + this.requestUpdate(); + } + + private renderSoundSettings() { + const sectionHeader = (key: string) => html` +

+ ${translateText(key)} +

+ `; + + return html` + ${sectionHeader("user_setting.sounds_category_volume")} + + + + + + ${sectionHeader("user_setting.sounds_category_ui")} + + this.toggleSoundEffect("click")} + > + + this.toggleSoundEffect("game-start")} + > + + this.toggleSoundEffect("message")} + > + + this.toggleSoundEffect("ka-ching")} + > + + ${sectionHeader("user_setting.sounds_category_weapons")} + + this.toggleSoundEffect("atom-launch")} + > + + this.toggleSoundEffect("atom-hit")} + > + + this.toggleSoundEffect("hydrogen-launch")} + > + + this.toggleSoundEffect("hydrogen-hit")} + > + + this.toggleSoundEffect("mirv-launch")} + > + + ${sectionHeader("user_setting.sounds_category_diplomacy")} + + this.toggleSoundEffect("alliance-suggested")} + > + + this.toggleSoundEffect("alliance-broken")} + > + + ${sectionHeader("user_setting.sounds_category_construction")} + + this.toggleSoundEffect("build-city")} + > + + this.toggleSoundEffect("build-port")} + > + + this.toggleSoundEffect("build-defense-post")} + > + + this.toggleSoundEffect("build-warship")} + > + + this.toggleSoundEffect("sam-built")} + > + + this.toggleSoundEffect("upgrade")} + > + + this.toggleSoundEffect("add-ammo")} + > + `; + } + + private toggleFxEffect(effect: string) { + this.userSettings.setFxEnabled( + effect, + !this.userSettings.isFxEnabled(effect), + ); + this.requestUpdate(); + } + + private renderEffectSettings() { + const sectionHeader = (key: string) => html` +

+ ${translateText(key)} +

+ `; + + const fxOff = !this.userSettings.fxLayer(); + const alertOff = !this.userSettings.alertFrame(); + + return html` + + + + + + ${sectionHeader("user_setting.fx_category_environment")} + + this.toggleFxEffect("fx-conquest")} + > + + this.toggleFxEffect("fx-dust")} + > + + ${sectionHeader("user_setting.fx_category_combat")} + + this.toggleFxEffect("fx-building-explosion")} + > + + this.toggleFxEffect("fx-shell-impact")} + > + + this.toggleFxEffect("fx-warship-sinking")} + > + + ${sectionHeader("user_setting.fx_category_nuclear")} + + this.toggleFxEffect("fx-nuke-telegraph")} + > + + this.toggleFxEffect("fx-nuke-explosion")} + > + + this.toggleFxEffect("fx-nuke-debris")} + > + + this.toggleFxEffect("fx-sam-interception")} + > + + ${sectionHeader("user_setting.fx_category_alerts")} + + this.toggleFxEffect("alert-land-attack")} + > + + this.toggleFxEffect("alert-betrayal")} + > + `; + } + private renderKeybindSettings() { return html`
- - - - - - diff --git a/src/client/components/baseComponents/setting/SettingToggle.ts b/src/client/components/baseComponents/setting/SettingToggle.ts index 79dab8b976..de99928162 100644 --- a/src/client/components/baseComponents/setting/SettingToggle.ts +++ b/src/client/components/baseComponents/setting/SettingToggle.ts @@ -8,6 +8,7 @@ export class SettingToggle extends LitElement { @property() id = ""; @property({ type: Boolean, reflect: true }) checked = false; @property({ type: Boolean }) easter = false; + @property({ type: Boolean }) disabled = false; createRenderRoot() { return this; @@ -23,9 +24,13 @@ export class SettingToggle extends LitElement { ? "bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)] bg-[length:1400%_1400%] animate-rainbow-bg text-white hover:bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)]" : ""; + const disabledClass = this.disabled + ? "opacity-40 pointer-events-none cursor-not-allowed" + : ""; + return html`