From 5ddc25897f0a7707711a3d5ba6df5d5ca170e8a3 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Fri, 9 May 2025 01:00:25 +0900 Subject: [PATCH] Add quick chat (#412) ## Description: Fixes #480 ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [ ] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: --- resources/QuickChat.json | 224 +++++++++++++++ resources/images/ChatIconWhite.svg | 13 + src/client/Main.ts | 6 + src/client/Transport.ts | 20 ++ src/client/graphics/GameRenderer.ts | 18 ++ src/client/graphics/layers/ChatDisplay.ts | 193 +++++++++++++ src/client/graphics/layers/ChatModal.ts | 292 ++++++++++++++++++++ src/client/graphics/layers/EventsDisplay.ts | 2 + src/client/graphics/layers/PlayerPanel.ts | 19 ++ src/client/index.html | 8 + src/client/styles.css | 1 + src/client/styles/modal/chat.css | 94 +++++++ src/core/Schemas.ts | 17 ++ src/core/execution/ExecutionManager.ts | 8 + src/core/execution/QuickChatExecution.ts | 98 +++++++ src/core/game/Game.ts | 1 + 16 files changed, 1014 insertions(+) create mode 100644 resources/QuickChat.json create mode 100644 resources/images/ChatIconWhite.svg create mode 100644 src/client/graphics/layers/ChatDisplay.ts create mode 100644 src/client/graphics/layers/ChatModal.ts create mode 100644 src/client/styles/modal/chat.css create mode 100644 src/core/execution/QuickChatExecution.ts diff --git a/resources/QuickChat.json b/resources/QuickChat.json new file mode 100644 index 000000000..82777e696 --- /dev/null +++ b/resources/QuickChat.json @@ -0,0 +1,224 @@ +{ + "help": [ + { + "key": "troops", + "text": "Please give me troops!", + "requiresPlayer": false + }, + { + "key": "gold", + "text": "Please give me gold!", + "requiresPlayer": false + }, + { + "key": "no_attack", + "text": "Please don't attack me!", + "requiresPlayer": false + }, + { + "key": "sorry_attack", + "text": "Sorry, I didn’t mean to attack.", + "requiresPlayer": false + }, + { + "key": "alliance", + "text": "Alliance?", + "requiresPlayer": false + }, + { + "key": "help_defend", + "text": "Help me defend against [P1]!", + "requiresPlayer": true + }, + { + "key": "team_up", + "text": "Let’s team up against [P1]!", + "requiresPlayer": true + } + ], + "attack": [ + { + "key": "attack", + "text": "Attack [P1]!", + "requiresPlayer": true + }, + { + "key": "mirv", + "text": "Launch a MIRV at [P1]!", + "requiresPlayer": true + }, + { + "key": "focus", + "text": "Focus fire on [P1]!", + "requiresPlayer": true + }, + { + "key": "finish", + "text": "Let's finish off [P1]!", + "requiresPlayer": true + } + ], + "defend": [ + { + "key": "defend", + "text": "Defend [P1]!", + "requiresPlayer": true + }, + { + "key": "dont_attack", + "text": "Don’t attack [P1]!", + "requiresPlayer": true + }, + { + "key": "ally", + "text": "[P1] is my ally!", + "requiresPlayer": true + } + ], + "greet": [ + { + "key": "hello", + "text": "Hello!", + "requiresPlayer": false + }, + { + "key": "good_luck", + "text": "Good luck!", + "requiresPlayer": false + }, + { + "key": "have_fun", + "text": "Have fun!", + "requiresPlayer": false + }, + { + "key": "gg", + "text": "GG!", + "requiresPlayer": false + }, + { + "key": "nice_to_meet", + "text": "Nice to meet you!", + "requiresPlayer": false + }, + { + "key": "well_played", + "text": "Well played!", + "requiresPlayer": false + }, + { + "key": "hi_again", + "text": "Hi again!", + "requiresPlayer": false + }, + { + "key": "bye", + "text": "Bye!", + "requiresPlayer": false + }, + { + "key": "thanks", + "text": "Thanks!", + "requiresPlayer": false + }, + { + "key": "oops", + "text": "Oops, wrong button!", + "requiresPlayer": false + }, + { + "key": "trust_me", + "text": "You can trust me. Promise!", + "requiresPlayer": false + }, + { + "key": "trust_broken", + "text": "I trusted you...", + "requiresPlayer": false + } + ], + "misc": [ + { + "key": "go", + "text": "Let’s go!", + "requiresPlayer": false + }, + { + "key": "strategy", + "text": "Nice strategy!", + "requiresPlayer": false + }, + { + "key": "fun", + "text": "This game is fun!", + "requiresPlayer": false + }, + { + "key": "pr", + "text": "When will my PR finally get merged...?", + "requiresPlayer": false + } + ], + "warnings": [ + { + "key": "strong", + "text": "[P1] is strong.", + "requiresPlayer": true + }, + { + "key": "weak", + "text": "[P1] is weak.", + "requiresPlayer": true + }, + { + "key": "mirv_soon", + "text": "[P1] can launch a MIRV soon!", + "requiresPlayer": true + }, + { + "key": "number1_warning", + "text": "The #1 player will win soon unless we team up!", + "requiresPlayer": false + }, + { + "key": "stalemate", + "text": "Let's make peace. This is a stalemate, we will both lose.", + "requiresPlayer": false + }, + { + "key": "has_allies", + "text": "[P1] has many allies.", + "requiresPlayer": true + }, + { + "key": "no_allies", + "text": "[P1] has no allies.", + "requiresPlayer": true + }, + { + "key": "betrayed", + "text": "[P1] betrayed their ally!", + "requiresPlayer": true + }, + { + "key": "getting_big", + "text": "[P1] is growing too fast!", + "requiresPlayer": true + }, + { + "key": "danger_base", + "text": "[P1] is unprotected!", + "requiresPlayer": true + }, + { + "key": "saving_for_mirv", + "text": "[P1] is saving up to launch a MIRV.", + "requiresPlayer": true + }, + { + "key": "mirv_ready", + "text": "[P1] has enough gold to launch a MIRV!", + "requiresPlayer": true + } + ] +} diff --git a/resources/images/ChatIconWhite.svg b/resources/images/ChatIconWhite.svg new file mode 100644 index 000000000..56c7d8e5b --- /dev/null +++ b/resources/images/ChatIconWhite.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/client/Main.ts b/src/client/Main.ts index 31a2a9ab4..a8fddd2e5 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -122,6 +122,12 @@ class Client { } }); + // const ctModal = document.querySelector("chat-modal") as ChatModal; + // ctModal instanceof ChatModal; + // document.getElementById("chat-button").addEventListener("click", () => { + // ctModal.open(); + // }); + const hlpModal = document.querySelector("help-modal") as HelpModal; hlpModal instanceof HelpModal; document.getElementById("help-button").addEventListener("click", () => { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index a9ee93fe9..ad2a13dd5 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -108,6 +108,15 @@ export class SendDonateTroopsIntentEvent implements GameEvent { ) {} } +export class SendQuickChatEvent implements GameEvent { + constructor( + public readonly sender: PlayerView, + public readonly recipient: PlayerView, + public readonly quickChatKey: string, + public readonly variables: { [key: string]: string }, + ) {} +} + export class SendEmbargoIntentEvent implements GameEvent { constructor( public readonly sender: PlayerView, @@ -196,6 +205,7 @@ export class Transport { this.eventBus.on(SendDonateTroopsIntentEvent, (e) => this.onSendDonateTroopIntent(e), ); + this.eventBus.on(SendQuickChatEvent, (e) => this.onSendQuickChatIntent(e)); this.eventBus.on(SendEmbargoIntentEvent, (e) => this.onSendEmbargoIntent(e), ); @@ -458,6 +468,16 @@ export class Transport { }); } + private onSendQuickChatIntent(event: SendQuickChatEvent) { + this.sendIntent({ + type: "quick_chat", + clientID: this.lobbyConfig.clientID, + recipient: event.recipient.id(), + quickChatKey: event.quickChatKey, + variables: event.variables, + }); + } + private onSendEmbargoIntent(event: SendEmbargoIntentEvent) { this.sendIntent({ type: "embargo", diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 9c92a3d0e..17f8bc30f 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -7,6 +7,8 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; import { BuildMenu } from "./layers/BuildMenu"; +import { ChatDisplay } from "./layers/ChatDisplay"; +import { ChatModal } from "./layers/ChatModal"; import { ControlPanel } from "./layers/ControlPanel"; import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; @@ -87,6 +89,14 @@ export function createRenderer( eventsDisplay.game = game; eventsDisplay.clientID = clientID; + const chatDisplay = document.querySelector("chat-display") as ChatDisplay; + if (!(chatDisplay instanceof ChatDisplay)) { + consolex.error("chat display not found"); + } + chatDisplay.eventBus = eventBus; + chatDisplay.game = game; + chatDisplay.clientID = clientID; + const playerInfo = document.querySelector( "player-info-overlay", ) as PlayerInfoOverlay; @@ -126,6 +136,13 @@ export function createRenderer( playerPanel.eventBus = eventBus; playerPanel.emojiTable = emojiTable; + const chatModal = document.querySelector("chat-modal") as ChatModal; + if (!(chatModal instanceof ChatModal)) { + console.error("chat modal not found"); + } + chatModal.g = game; + chatModal.eventBus = eventBus; + const multiTabModal = document.querySelector( "multi-tab-modal", ) as MultiTabModal; @@ -142,6 +159,7 @@ export function createRenderer( new UILayer(game, eventBus, clientID, transformHandler), new NameLayer(game, transformHandler, clientID), eventsDisplay, + chatDisplay, buildMenu, new RadialMenu( eventBus, diff --git a/src/client/graphics/layers/ChatDisplay.ts b/src/client/graphics/layers/ChatDisplay.ts new file mode 100644 index 000000000..847dd35d0 --- /dev/null +++ b/src/client/graphics/layers/ChatDisplay.ts @@ -0,0 +1,193 @@ +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { DirectiveResult } from "lit/directive.js"; +import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js"; +import { EventBus } from "../../../core/EventBus"; +import { MessageType } from "../../../core/game/Game"; +import { + DisplayMessageUpdate, + GameUpdateType, +} from "../../../core/game/GameUpdates"; +import { GameView } from "../../../core/game/GameView"; +import { ClientID } from "../../../core/Schemas"; +import { onlyImages } from "../../../core/Util"; +import { Layer } from "./Layer"; + +interface ChatEvent { + description: string; + unsafeDescription?: boolean; + createdAt: number; + highlight?: boolean; +} + +@customElement("chat-display") +export class ChatDisplay extends LitElement implements Layer { + public eventBus: EventBus; + public game: GameView; + public clientID: ClientID; + + private active: boolean = false; + + private updateMap = new Map([ + [GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)], + ]); + + @state() private _hidden: boolean = false; + @state() private newEvents: number = 0; + @state() private chatEvents: ChatEvent[] = []; + + private toggleHidden() { + this._hidden = !this._hidden; + if (this._hidden) { + this.newEvents = 0; + } + this.requestUpdate(); + } + + private addEvent(event: ChatEvent) { + this.chatEvents = [...this.chatEvents, event]; + if (this._hidden) { + this.newEvents++; + } + this.requestUpdate(); + } + + private removeEvent(index: number) { + this.chatEvents = [ + ...this.chatEvents.slice(0, index), + ...this.chatEvents.slice(index + 1), + ]; + } + + onDisplayMessageEvent(event: DisplayMessageUpdate) { + if (event.messageType !== MessageType.CHAT) return; + const myPlayer = this.game.playerByClientID(this.clientID); + if ( + event.playerID != null && + (!myPlayer || myPlayer.smallID() !== event.playerID) + ) { + return; + } + + this.addEvent({ + description: event.message, + createdAt: this.game.ticks(), + highlight: true, + unsafeDescription: true, + }); + } + + init() {} + + tick() { + // this.active = true; + const updates = this.game.updatesSinceLastTick(); + const messages = updates[GameUpdateType.DisplayEvent] as + | DisplayMessageUpdate[] + | undefined; + + if (messages) { + for (const msg of messages) { + if (msg.messageType === MessageType.CHAT) { + const myPlayer = this.game.playerByClientID(this.clientID); + if ( + msg.playerID != null && + (!myPlayer || myPlayer.smallID() !== msg.playerID) + ) { + continue; + } + + this.chatEvents = [ + ...this.chatEvents, + { + description: msg.message, + unsafeDescription: true, + createdAt: this.game.ticks(), + }, + ]; + } + } + } + + if (this.chatEvents.length > 100) { + this.chatEvents = this.chatEvents.slice(-100); + } + + this.requestUpdate(); + } + + private getChatContent( + chat: ChatEvent, + ): string | DirectiveResult { + return chat.unsafeDescription + ? unsafeHTML(onlyImages(chat.description)) + : chat.description; + } + + render() { + if (!this.active) { + return html``; + } + return html` +
+
+
+ +
+ + + + + + ${this.chatEvents.map( + (chat) => html` + + + + `, + )} + +
+ ${this.getChatContent(chat)} +
+
+
+ `; + } + + createRenderRoot() { + return this; + } +} diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts new file mode 100644 index 000000000..4d4447877 --- /dev/null +++ b/src/client/graphics/layers/ChatModal.ts @@ -0,0 +1,292 @@ +import { LitElement, html } from "lit"; +import { customElement, query } from "lit/decorators.js"; + +import { PlayerType } from "../../../core/game/Game"; +import { GameView, PlayerView } from "../../../core/game/GameView"; + +import quickChatData from "../../../../resources/QuickChat.json"; +import { EventBus } from "../../../core/EventBus"; +import { SendQuickChatEvent } from "../../Transport"; + +type QuickChatPhrase = { + key: string; + text: string; + requiresPlayer: boolean; +}; + +type QuickChatPhrases = Record; + +const quickChatPhrases: QuickChatPhrases = quickChatData; + +@customElement("chat-modal") +export class ChatModal extends LitElement { + @query("o-modal") private modalEl!: HTMLElement & { + open: () => void; + close: () => void; + }; + + createRenderRoot() { + return this; + } + + private players: string[] = []; + + private playerSearchQuery: string = ""; + private previewText: string | null = null; + private requiresPlayerSelection: boolean = false; + private selectedCategory: string | null = null; + private selectedPhraseText: string | null = null; + private selectedPlayer: string | null = null; + private selectedPhraseTemplate: string | null = null; + private selectedQuickChatKey: string | null = null; + + private recipient: PlayerView; + private sender: PlayerView; + public eventBus: EventBus; + + public g: GameView; + + quickChatPhrases: Record< + string, + Array<{ text: string; requiresPlayer: boolean }> + > = { + help: [{ text: "Please give me troops!", requiresPlayer: false }], + attack: [{ text: "Attack [P1]!", requiresPlayer: true }], + defend: [{ text: "Defend [P1]!", requiresPlayer: true }], + greet: [{ text: "Hello!", requiresPlayer: false }], + misc: [{ text: "Let's go!", requiresPlayer: false }], + }; + + private categories = [ + { id: "help", name: "Help" }, + { id: "attack", name: "Attack" }, + { id: "defend", name: "Defend" }, + { id: "greet", name: "Greetings" }, + { id: "misc", name: "Miscellaneous" }, + { id: "warnings", name: "Warnings" }, + ]; + + private getPhrasesForCategory(categoryId: string) { + return quickChatPhrases[categoryId] ?? []; + } + + render() { + const sortedPlayers = [...this.players].sort((a, b) => a.localeCompare(b)); + + const filteredPlayers = sortedPlayers.filter((player) => + player.toLowerCase().includes(this.playerSearchQuery), + ); + + const otherPlayers = sortedPlayers.filter( + (player) => !player.toLowerCase().includes(this.playerSearchQuery), + ); + + const displayPlayers = [...filteredPlayers, ...otherPlayers]; + return html` + +
+
+
Category
+ ${this.categories.map( + (category) => html` + + `, + )} +
+ + ${this.selectedCategory + ? html` +
+
Phrase
+
+ ${this.getPhrasesForCategory(this.selectedCategory).map( + (phrase) => html` + + `, + )} +
+
+ ` + : null} + ${this.requiresPlayerSelection || this.selectedPlayer + ? html` +
+
Player
+ + + +
+ ${this.getSortedFilteredPlayers().map( + (player) => html` + + `, + )} +
+
+ ` + : null} +
+ +
+ ${this.previewText || "Build your message..."} +
+
+ +
+
+ `; + } + + private selectCategory(categoryId: string) { + this.selectedCategory = categoryId; + this.selectedPhraseText = null; + this.previewText = null; + this.requiresPlayerSelection = false; + this.selectedPlayer = null; + this.requestUpdate(); + } + + private selectPhrase(phrase: QuickChatPhrase) { + this.selectedPhraseTemplate = phrase.text; + this.selectedPhraseText = phrase.text; + this.selectedQuickChatKey = this.getFullQuickChatKey( + this.selectedCategory!, + phrase.key, + ); + this.previewText = phrase.text; + this.requiresPlayerSelection = phrase.requiresPlayer; + this.selectedPlayer = null; + this.requestUpdate(); + } + + private renderPhrasePreview(phrase: { text: string }) { + return phrase.text.replace("[P1]", "___"); // 仮表示 + } + + private selectPlayer(player: string) { + if (this.previewText) { + this.previewText = this.selectedPhraseTemplate.replace("[P1]", player); + this.selectedPlayer = player; + this.requiresPlayerSelection = false; + this.requestUpdate(); + } + } + + private sendChatMessage() { + console.log("Sent message:", this.previewText); + console.log("Sender:", this.sender); + console.log("Recipient:", this.recipient); + console.log("Key:", this.selectedQuickChatKey); + + if (this.sender && this.recipient && this.selectedQuickChatKey) { + const variables = this.selectedPlayer ? { P1: this.selectedPlayer } : {}; + + this.eventBus.emit( + new SendQuickChatEvent( + this.sender, + this.recipient, + this.selectedQuickChatKey, + variables, + ), + ); + } + + this.previewText = null; + this.selectedCategory = null; + this.requiresPlayerSelection = false; + this.close(); + + this.requestUpdate(); + } + + private onPlayerSearchInput(e: Event) { + const target = e.target as HTMLInputElement; + this.playerSearchQuery = target.value.toLowerCase(); + this.requestUpdate(); + } + + private getSortedFilteredPlayers(): string[] { + const sorted = [...this.players].sort((a, b) => a.localeCompare(b)); + const filtered = sorted.filter((p) => + p.toLowerCase().includes(this.playerSearchQuery), + ); + const others = sorted.filter( + (p) => !p.toLowerCase().includes(this.playerSearchQuery), + ); + return [...filtered, ...others]; + } + + private getFullQuickChatKey(category: string, phraseKey: string): string { + return `${category}.${phraseKey}`; + } + + public open(sender?: PlayerView, recipient?: PlayerView) { + if (sender && recipient) { + console.log("Sent message:", recipient); + console.log("Sent message:", sender); + const alivePlayerNames = this.g + .players() + .filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot)) + .map((p) => p.data.name); + + console.log("Alive player names:", alivePlayerNames); + this.players = alivePlayerNames; + this.recipient = recipient; + this.sender = sender; + } + this.modalEl?.open(); + } + + public close() { + this.selectedCategory = null; + this.selectedPhraseText = null; + this.previewText = null; + this.requiresPlayerSelection = false; + this.selectedPlayer = null; + this.modalEl?.close(); + } + + public setRecipient(value: PlayerView) { + this.recipient = value; + } + + public setSender(value: PlayerView) { + this.sender = value; + } +} diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 48f812c3f..95fd89c54 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -395,6 +395,8 @@ export class EventsDisplay extends LitElement implements Layer { return "text-green-300"; case MessageType.INFO: return "text-gray-200"; + case MessageType.CHAT: + return "text-gray-200"; case MessageType.WARN: return "text-yellow-300"; case MessageType.ERROR: diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 371b45989..bc4977322 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; +import chatIcon from "../../../../resources/images/ChatIconWhite.svg"; import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg"; import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg"; import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg"; @@ -27,6 +28,7 @@ import { SendTargetPlayerIntentEvent, } from "../../Transport"; import { renderNumber, renderTroops } from "../../Utils"; +import { ChatModal } from "./ChatModal"; import { EmojiTable } from "./EmojiTable"; import { Layer } from "./Layer"; @@ -139,6 +141,11 @@ export class PlayerPanel extends LitElement implements Layer { }); } + private handleChat(e: Event, sender: PlayerView, other: PlayerView) { + this.ctModal.open(sender, other); + this.hide(); + } + private handleTargetClick(e: Event, other: PlayerView) { e.stopPropagation(); this.eventBus.emit(new SendTargetPlayerIntentEvent(other.id())); @@ -149,8 +156,12 @@ export class PlayerPanel extends LitElement implements Layer { return this; } + private ctModal; + init() { this.eventBus.on(MouseUpEvent, (e: MouseEvent) => this.hide()); + + this.ctModal = document.querySelector("chat-modal") as ChatModal; } async tick() { @@ -295,6 +306,14 @@ export class PlayerPanel extends LitElement implements Layer {
+ ${canTarget ? html`
+
@@ -372,6 +379,7 @@ +
; @@ -50,6 +52,7 @@ export type TargetTroopRatioIntent = z.infer< >; export type BuildUnitIntent = z.infer; export type MoveWarshipIntent = z.infer; +export type QuickChatIntent = z.infer; export type Turn = z.infer; export type GameConfig = z.infer; @@ -270,6 +273,19 @@ export const MoveWarshipIntentSchema = BaseIntentSchema.extend({ tile: z.number(), }); +export const QuickChatKeySchema = z.enum( + Object.entries(quickChatData).flatMap(([category, entries]) => + entries.map((entry) => `${category}.${entry.key}`), + ) as [string, ...string[]], +); + +export const QuickChatIntentSchema = BaseIntentSchema.extend({ + type: z.literal("quick_chat"), + recipient: ID, + quickChatKey: QuickChatKeySchema, + variables: z.record(SafeString).optional(), +}); + const IntentSchema = z.union([ AttackIntentSchema, CancelAttackIntentSchema, @@ -286,6 +302,7 @@ const IntentSchema = z.union([ BuildUnitIntentSchema, EmbargoIntentSchema, MoveWarshipIntentSchema, + QuickChatIntentSchema, ]); export const TurnSchema = z.object({ diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 73bfb97e9..15c3c3241 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -15,6 +15,7 @@ import { EmojiExecution } from "./EmojiExecution"; import { FakeHumanExecution } from "./FakeHumanExecution"; import { MoveWarshipExecution } from "./MoveWarshipExecution"; import { NoOpExecution } from "./NoOpExecution"; +import { QuickChatExecution } from "./QuickChatExecution"; import { RetreatExecution } from "./RetreatExecution"; import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution"; import { SpawnExecution } from "./SpawnExecution"; @@ -108,6 +109,13 @@ export class Executor { this.mg.ref(intent.x, intent.y), intent.unit, ); + case "quick_chat": + return new QuickChatExecution( + playerID, + intent.recipient, + intent.quickChatKey, + intent.variables ?? {}, + ); default: throw new Error(`intent type ${intent} not found`); } diff --git a/src/core/execution/QuickChatExecution.ts b/src/core/execution/QuickChatExecution.ts new file mode 100644 index 000000000..919979997 --- /dev/null +++ b/src/core/execution/QuickChatExecution.ts @@ -0,0 +1,98 @@ +import quickChatData from "../../../resources/QuickChat.json"; +import { consolex } from "../Consolex"; +import { Execution, Game, MessageType, Player, PlayerID } from "../game/Game"; + +export class QuickChatExecution implements Execution { + private sender: Player; + private recipient: Player; + private mg: Game; + + private active = true; + + constructor( + private senderID: PlayerID, + private recipientID: PlayerID, + private quickChatKey: string, + private variables: Record, + ) {} + + init(mg: Game, ticks: number): void { + this.mg = mg; + if (!mg.hasPlayer(this.senderID)) { + consolex.warn(`QuickChatExecution: sender ${this.senderID} not found`); + this.active = false; + return; + } + if (!mg.hasPlayer(this.recipientID)) { + consolex.warn( + `QuickChatExecution: recipient ${this.recipientID} not found`, + ); + this.active = false; + return; + } + + this.sender = mg.player(this.senderID); + this.recipient = mg.player(this.recipientID); + } + + tick(ticks: number): void { + const message = this.getMessageFromKey(this.quickChatKey, this.variables); + + this.mg.displayMessage( + `${this.sender.name()}: ${message}`, + MessageType.CHAT, + this.recipient.id(), + ); + + this.mg.displayMessage( + `You sent to ${this.recipient.name()}: ${message}`, + MessageType.CHAT, + this.sender.id(), + ); + + consolex.log( + `[QuickChat] ${this.sender.name} → ${this.recipient.name}: ${message}`, + ); + + this.active = false; + } + + owner(): Player { + return this.sender; + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } + + private getMessageFromKey( + fullKey: string, + vars: Record, + ): string { + // Key for translation + const [category, key] = fullKey.split("."); + const phrases = quickChatData[category]; + + if (!phrases) { + consolex.warn(`QuickChat: Unknown category '${category}'`); + return `[${fullKey}]`; + } + + const phraseObj = phrases.find((p) => p.key === key); + if (!phraseObj) { + consolex.warn( + `QuickChat: Key '${key}' not found in category '${category}'`, + ); + return `[${fullKey}]`; + } + + return phraseObj.text.replace( + /\[(\w+)\]/g, + (_, p1) => vars[p1] || `[${p1}]`, + ); + } +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 17cc19e63..492dd8489 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -557,6 +557,7 @@ export enum MessageType { INFO, WARN, ERROR, + CHAT, } export interface NameViewData {