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:

<DISCORD USERNAME>
This commit is contained in:
Aotumuri
2025-05-09 01:00:25 +09:00
committed by GitHub
parent 5de469e312
commit 5ddc25897f
16 changed files with 1014 additions and 0 deletions
+224
View File
@@ -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 didnt 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": "Lets 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": "Dont 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": "Lets 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
}
]
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="svg4" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" viewBox="0 0 800 800">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<sodipodi:namedview id="namedview6" bordercolor="#000000" borderopacity="0.25" inkscape:current-layer="svg4" inkscape:cx="401.69492" inkscape:cy="400" inkscape:deskcolor="#d1d1d1" inkscape:pagecheckerboard="0" inkscape:pageopacity="0.0" inkscape:showpageshadow="2" inkscape:window-height="987" inkscape:window-maximized="1" inkscape:window-width="1536" inkscape:window-x="0" inkscape:window-y="0" inkscape:zoom="0.295" pagecolor="#ffffff" showgrid="false"/>
<path class="st0" d="M713.8,149.3v401c0,14.1-11.4,25.5-25.4,25.5h-432.2l-124.4,129.8v-129.8h-20.1c-14.1,0-25.5-11.4-25.5-25.5V149.3c0-14.1,11.3-25.5,25.4-25.5h576.7c14.1,0,25.5,11.4,25.5,25.5ZM400,317.5c-19.2,0-34.8,15.6-34.8,34.8s15.6,34.8,34.8,34.8,34.8-15.6,34.8-34.8-15.6-34.8-34.8-34.8ZM561.1,317.5c-19.2,0-34.8,15.6-34.8,34.8s15.6,34.8,34.8,34.8,34.8-15.6,34.8-34.8-15.6-34.8-34.8-34.8ZM238.9,317.5c-19.2,0-34.8,15.6-34.8,34.8s15.6,34.8,34.8,34.8,34.8-15.6,34.8-34.8-15.6-34.8-34.8-34.8h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+6
View File
@@ -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", () => {
+20
View File
@@ -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",
+18
View File
@@ -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,
+193
View File
@@ -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<typeof UnsafeHTMLDirective> {
return chat.unsafeDescription
? unsafeHTML(onlyImages(chat.description))
: chat.description;
}
render() {
if (!this.active) {
return html``;
}
return html`
<div
class="${this._hidden
? "w-fit px-[10px] py-[5px]"
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-[30vw] lg:w-full lg:w-auto"
style="pointer-events: auto"
>
<div>
<div class="w-full bg-black/80 sticky top-0 px-[10px]">
<button
class="text-white cursor-pointer pointer-events-auto ${this
._hidden
? "hidden"
: ""}"
@click=${this.toggleHidden}
>
Hide
</button>
</div>
<button
class="text-white cursor-pointer pointer-events-auto ${this._hidden
? ""
: "hidden"}"
@click=${this.toggleHidden}
>
Chat
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-sm"
>${this.newEvents}</span
>
</button>
<table
class="w-full border-collapse text-white shadow-lg lg:text-xl text-xs ${this
._hidden
? "hidden"
: ""}"
style="pointer-events: auto;"
>
<tbody>
${this.chatEvents.map(
(chat) => html`
<tr class="border-b border-opacity-0">
<td class="lg:p-3 p-1 text-left">
${this.getChatContent(chat)}
</td>
</tr>
`,
)}
</tbody>
</table>
</div>
</div>
`;
}
createRenderRoot() {
return this;
}
}
+292
View File
@@ -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<string, QuickChatPhrase[]>;
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`
<o-modal title="Quick Chat">
<div class="chat-columns">
<div class="chat-column">
<div class="column-title">Category</div>
${this.categories.map(
(category) => html`
<button
class="chat-option-button ${this.selectedCategory ===
category.id
? "selected"
: ""}"
@click=${() => this.selectCategory(category.id)}
>
${category.name}
</button>
`,
)}
</div>
${this.selectedCategory
? html`
<div class="chat-column">
<div class="column-title">Phrase</div>
<div class="phrase-scroll-area">
${this.getPhrasesForCategory(this.selectedCategory).map(
(phrase) => html`
<button
class="chat-option-button ${this
.selectedPhraseText === phrase.text
? "selected"
: ""}"
@click=${() => this.selectPhrase(phrase)}
>
${this.renderPhrasePreview(phrase)}
</button>
`,
)}
</div>
</div>
`
: null}
${this.requiresPlayerSelection || this.selectedPlayer
? html`
<div class="chat-column">
<div class="column-title">Player</div>
<input
class="player-search-input"
type="text"
placeholder="Search player..."
.value=${this.playerSearchQuery}
@input=${this.onPlayerSearchInput}
/>
<div class="player-scroll-area">
${this.getSortedFilteredPlayers().map(
(player) => html`
<button
class="chat-option-button ${this.selectedPlayer ===
player
? "selected"
: ""}"
@click=${() => this.selectPlayer(player)}
>
${player}
</button>
`,
)}
</div>
</div>
`
: null}
</div>
<div class="chat-preview">
${this.previewText || "Build your message..."}
</div>
<div class="chat-send">
<button
class="chat-send-button"
@click=${this.sendChatMessage}
?disabled=${!this.previewText}
>
Send
</button>
</div>
</o-modal>
`;
}
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;
}
}
@@ -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:
+19
View File
@@ -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 {
<!-- Action buttons -->
<div class="flex justify-center gap-2">
<button
@click=${(e) => this.handleChat(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${chatIcon} alt="Target" class="w-6 h-6" />
</button>
${canTarget
? html`<button
@click=${(e) => this.handleTargetClick(e, other)}
+8
View File
@@ -250,6 +250,12 @@
block
secondary
></o-button>
<!-- <o-button
id="chat-button"
title="Chat Test"
block
secondary
></o-button> -->
</div>
<o-button
@@ -307,6 +313,7 @@
class="w-full sm:w-2/3 sm:fixed sm:right-0 sm:bottom-0 sm:flex justify-end"
style="pointer-events: none"
>
<chat-display></chat-display>
<events-display></events-display>
</div>
<div class="w-full sm:w-1/3 md:max-w-72" style="pointer-events: auto">
@@ -372,6 +379,7 @@
<player-panel></player-panel>
<help-modal></help-modal>
<dark-mode-button></dark-mode-button>
<chat-modal></chat-modal>
<user-setting></user-setting>
<multi-tab-modal></multi-tab-modal>
<div
+1
View File
@@ -8,6 +8,7 @@
@import url("./styles/layout/container.css");
@import url("./styles/components/button.css");
@import url("./styles/components/modal.css");
@import url("./styles/modal/chat.css");
@import url("./styles/components/setting.css");
@import url("./styles/components/controls.css");
* {
+94
View File
@@ -0,0 +1,94 @@
/* .w. */
.chat-columns {
display: flex;
gap: 16px;
padding: 12px;
overflow-x: auto;
}
.chat-column {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 120px;
}
.column-title {
font-weight: bold;
margin-bottom: 4px;
}
.chat-option-button {
background: #333;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
text-align: left;
cursor: pointer;
}
.chat-option-button.selected {
background-color: #66c;
}
.chat-preview {
margin: 10px 12px;
padding: 10px;
background: #222;
color: white;
border-radius: 6px;
text-align: center;
}
.chat-send {
display: flex;
justify-content: flex-end;
padding: 0 12px 12px;
}
.chat-send-button {
background: #4caf50;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.chat-column {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 140px;
}
.player-search-input {
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #666;
font-size: 14px;
outline: none;
background-color: #fff;
color: #000;
}
.player-scroll-area {
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
.phrase-scroll-area {
max-height: 280px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
+17
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import quickChatData from "../../resources/QuickChat.json" with { type: "json" };
import {
AllPlayers,
Difficulty,
@@ -29,6 +30,7 @@ export type Intent =
| TargetTroopRatioIntent
| BuildUnitIntent
| EmbargoIntent
| QuickChatIntent
| MoveWarshipIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
@@ -50,6 +52,7 @@ export type TargetTroopRatioIntent = z.infer<
>;
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -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({
+8
View File
@@ -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`);
}
+98
View File
@@ -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<string, string>,
) {}
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, string>,
): 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}]`,
);
}
}
+1
View File
@@ -557,6 +557,7 @@ export enum MessageType {
INFO,
WARN,
ERROR,
CHAT,
}
export interface NameViewData {