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-08 09:00:25 -07:00
committed by GitHub
parent 5de469e312
commit 5ddc25897f
16 changed files with 1014 additions and 0 deletions
+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)}