Skip to content

Commit b3ce41c

Browse files
committed
Extract game log UI view
1 parent ad6af48 commit b3ce41c

File tree

3 files changed

+364
-201
lines changed

3 files changed

+364
-201
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// @vitest-environment jsdom
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { GameLogView } from './game-log-view';
5+
6+
const installFixture = () => {
7+
document.body.innerHTML = `
8+
<div id="gameLog" style="display:none"></div>
9+
<div id="logEntries"></div>
10+
<div id="chatInputRow" style="display:none"></div>
11+
<input id="chatInput" />
12+
<button id="logShowBtn" style="display:none"></button>
13+
<button id="logToggleBtn"></button>
14+
<div id="logLatestBar" style="display:none"></div>
15+
<div id="logLatestText"></div>
16+
`;
17+
};
18+
19+
describe('GameLogView', () => {
20+
beforeEach(() => {
21+
installFixture();
22+
});
23+
24+
it('emits trimmed chat messages and clears the input', () => {
25+
const onChat = vi.fn<(text: string) => void>();
26+
const view = new GameLogView({ onChat });
27+
const input = document.getElementById('chatInput') as HTMLInputElement;
28+
29+
view.setChatEnabled(true);
30+
input.value = ' hello there ';
31+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
32+
33+
expect(onChat).toHaveBeenCalledWith('hello there');
34+
expect(input.value).toBe('');
35+
expect(
36+
(document.getElementById('chatInputRow') as HTMLElement).style.display,
37+
).toBe('');
38+
});
39+
40+
it('toggles desktop log visibility between panel and show button', () => {
41+
const view = new GameLogView({ onChat: vi.fn() });
42+
const gameLog = document.getElementById('gameLog') as HTMLElement;
43+
const logShowBtn = document.getElementById('logShowBtn') as HTMLElement;
44+
const logToggleBtn = document.getElementById('logToggleBtn') as HTMLElement;
45+
46+
view.setMobile(false, true);
47+
view.showHUD();
48+
expect(gameLog.style.display).toBe('flex');
49+
expect(logShowBtn.style.display).toBe('none');
50+
51+
logToggleBtn.click();
52+
expect(gameLog.style.display).toBe('none');
53+
expect(logShowBtn.style.display).toBe('block');
54+
55+
logShowBtn.click();
56+
expect(gameLog.style.display).toBe('flex');
57+
expect(logShowBtn.style.display).toBe('none');
58+
});
59+
60+
it('uses latest-bar behavior on mobile and removes empty turn headers', () => {
61+
const view = new GameLogView({ onChat: vi.fn() });
62+
const gameLog = document.getElementById('gameLog') as HTMLElement;
63+
const latestBar = document.getElementById('logLatestBar') as HTMLElement;
64+
const latestText = document.getElementById('logLatestText') as HTMLElement;
65+
66+
view.setMobile(true, false);
67+
view.showHUD();
68+
expect(gameLog.style.display).toBe('none');
69+
expect(latestBar.style.display).toBe('block');
70+
71+
view.logTurn(1, 'You');
72+
view.logTurn(2, 'Opponent');
73+
view.logText('Shot fired', 'log-combat');
74+
75+
const entries = Array.from(
76+
document.querySelectorAll('#logEntries .log-entry'),
77+
);
78+
expect(entries).toHaveLength(2);
79+
expect(entries[0]?.textContent).toBe('— Turn 2: Opponent —');
80+
expect(entries[1]?.textContent).toBe('Shot fired');
81+
expect(latestText.textContent).toBe('Shot fired');
82+
expect(latestText.className).toContain('log-combat');
83+
84+
view.toggle();
85+
expect(gameLog.style.display).toBe('flex');
86+
expect(latestBar.style.display).toBe('none');
87+
88+
view.toggle();
89+
expect(gameLog.style.display).toBe('none');
90+
expect(latestBar.style.display).toBe('block');
91+
});
92+
});

src/client/ui/game-log-view.ts

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import type { CombatResult, MovementEvent, Ship } from '../../shared/types';
2+
import { byId, el } from '../dom';
3+
import {
4+
formatCombatResultEntries,
5+
formatMovementEventEntry,
6+
} from './formatters';
7+
import {
8+
buildScreenVisibility,
9+
toggleLogVisible,
10+
type UIScreenMode,
11+
} from './screens';
12+
13+
export interface GameLogViewDeps {
14+
onChat: (text: string) => void;
15+
}
16+
17+
export class GameLogView {
18+
private readonly gameLogEl = byId('gameLog');
19+
private readonly logEntriesEl = byId('logEntries');
20+
private readonly chatInputRow = byId('chatInputRow');
21+
private readonly chatInput = byId<HTMLInputElement>('chatInput');
22+
private readonly logShowBtn = byId('logShowBtn');
23+
private readonly logLatestBar = byId('logLatestBar');
24+
private readonly logLatestText = byId('logLatestText');
25+
private readonly logToggleBtn = byId('logToggleBtn');
26+
27+
private lastTurnHeader: HTMLElement | null = null;
28+
private playerId = -1;
29+
private isMobile = false;
30+
private logVisible = true;
31+
private logExpandedOnMobile = false;
32+
33+
constructor(private readonly deps: GameLogViewDeps) {
34+
this.bindChatInput();
35+
this.bindLogControls();
36+
}
37+
38+
setPlayerId(id: number): void {
39+
this.playerId = id;
40+
}
41+
42+
setMobile(isMobile: boolean, hudVisible: boolean): void {
43+
this.isMobile = isMobile;
44+
this.syncVisibility(hudVisible);
45+
}
46+
47+
applyScreenVisibility(mode: UIScreenMode): void {
48+
const visibility = buildScreenVisibility(mode, this.logVisible);
49+
50+
this.gameLogEl.style.display = visibility.gameLog;
51+
this.logShowBtn.style.display = visibility.logShowBtn;
52+
}
53+
54+
resetVisibilityState(): void {
55+
this.logLatestBar.style.display = 'none';
56+
this.logExpandedOnMobile = false;
57+
this.gameLogEl.classList.remove('mobile-expanded');
58+
}
59+
60+
showHUD(): void {
61+
this.applyScreenVisibility('hud');
62+
63+
if (!this.isMobile) {
64+
return;
65+
}
66+
67+
this.gameLogEl.classList.remove('mobile-expanded');
68+
this.gameLogEl.style.display = 'none';
69+
this.logShowBtn.style.display = 'none';
70+
this.logLatestBar.style.display = 'block';
71+
this.logExpandedOnMobile = false;
72+
}
73+
74+
toggle(): void {
75+
if (this.isMobile) {
76+
if (this.logExpandedOnMobile) {
77+
this.collapseMobileLog();
78+
} else {
79+
this.expandMobileLog();
80+
}
81+
82+
return;
83+
}
84+
85+
this.logVisible = toggleLogVisible(this.logVisible);
86+
this.applyScreenVisibility('hud');
87+
}
88+
89+
clear(): void {
90+
this.logEntriesEl.innerHTML = '';
91+
this.lastTurnHeader = null;
92+
}
93+
94+
setChatEnabled(enabled: boolean): void {
95+
this.chatInputRow.style.display = enabled ? '' : 'none';
96+
this.chatInput.value = '';
97+
}
98+
99+
logTurn(turn: number, player: string): void {
100+
if (
101+
this.lastTurnHeader &&
102+
this.lastTurnHeader === this.logEntriesEl.lastElementChild
103+
) {
104+
this.logEntriesEl.removeChild(this.lastTurnHeader);
105+
}
106+
107+
const text = `\u2014 Turn ${turn}: ${player} \u2014`;
108+
const header = el('div', {
109+
class: 'log-entry log-turn',
110+
text,
111+
});
112+
113+
this.logEntriesEl.appendChild(header);
114+
this.lastTurnHeader = header;
115+
116+
this.scrollToBottom();
117+
this.updateLatestBar(text, 'log-turn');
118+
}
119+
120+
logText(text: string, cssClass = ''): void {
121+
this.logEntriesEl.appendChild(
122+
el('div', {
123+
class: `log-entry ${cssClass}`,
124+
text,
125+
}),
126+
);
127+
128+
this.scrollToBottom();
129+
this.updateLatestBar(text, cssClass);
130+
}
131+
132+
logMovementEvents(events: MovementEvent[], ships: Ship[]): void {
133+
for (const event of events) {
134+
const entry = formatMovementEventEntry(event, ships);
135+
if (entry) {
136+
this.logText(entry.text, entry.className);
137+
}
138+
}
139+
}
140+
141+
logCombatResults(results: CombatResult[], ships: Ship[]): void {
142+
for (const result of results) {
143+
for (const entry of formatCombatResultEntries(
144+
result,
145+
ships,
146+
this.playerId,
147+
)) {
148+
this.logText(entry.text, entry.className);
149+
}
150+
}
151+
}
152+
153+
logLanding(shipName: string, bodyName: string): void {
154+
this.logText(`${shipName} landed at ${bodyName}`, 'log-landed');
155+
}
156+
157+
private bindChatInput(): void {
158+
this.chatInput.addEventListener('keydown', (event) => {
159+
event.stopPropagation();
160+
161+
if (event.key !== 'Enter') {
162+
return;
163+
}
164+
165+
const text = this.chatInput.value.trim();
166+
if (!text) {
167+
return;
168+
}
169+
170+
this.deps.onChat(text);
171+
this.chatInput.value = '';
172+
});
173+
}
174+
175+
private bindLogControls(): void {
176+
this.logLatestBar.addEventListener('click', () => {
177+
this.expandMobileLog();
178+
});
179+
180+
this.logToggleBtn.addEventListener('click', () => {
181+
if (this.isMobile) {
182+
this.collapseMobileLog();
183+
184+
return;
185+
}
186+
187+
this.logVisible = false;
188+
this.applyScreenVisibility('hud');
189+
});
190+
191+
this.logShowBtn.addEventListener('click', () => {
192+
this.logVisible = true;
193+
this.applyScreenVisibility('hud');
194+
});
195+
}
196+
197+
private scrollToBottom(): void {
198+
this.logEntriesEl.scrollTop = this.logEntriesEl.scrollHeight;
199+
}
200+
201+
private updateLatestBar(text: string, cssClass: string): void {
202+
if (!this.isMobile) {
203+
return;
204+
}
205+
206+
this.logLatestText.textContent = text;
207+
this.logLatestText.className = `log-latest-text ${cssClass}`;
208+
}
209+
210+
private expandMobileLog(): void {
211+
this.logExpandedOnMobile = true;
212+
this.gameLogEl.classList.add('mobile-expanded');
213+
this.gameLogEl.style.display = 'flex';
214+
this.logLatestBar.style.display = 'none';
215+
this.scrollToBottom();
216+
}
217+
218+
private collapseMobileLog(): void {
219+
this.logExpandedOnMobile = false;
220+
this.gameLogEl.classList.remove('mobile-expanded');
221+
this.gameLogEl.style.display = 'none';
222+
this.logLatestBar.style.display = 'block';
223+
}
224+
225+
private syncVisibility(hudVisible: boolean): void {
226+
if (!hudVisible) {
227+
return;
228+
}
229+
230+
if (this.isMobile) {
231+
this.gameLogEl.classList.remove('mobile-expanded');
232+
this.gameLogEl.style.display = 'none';
233+
this.logShowBtn.style.display = 'none';
234+
this.logLatestBar.style.display = 'block';
235+
this.logExpandedOnMobile = false;
236+
return;
237+
}
238+
239+
this.gameLogEl.classList.remove('mobile-expanded');
240+
this.logLatestBar.style.display = 'none';
241+
this.logExpandedOnMobile = false;
242+
this.applyScreenVisibility('hud');
243+
}
244+
}

0 commit comments

Comments
 (0)