Skip to content

Commit defe796

Browse files
Fix map, chat, states
1 parent fb46e07 commit defe796

15 files changed

Lines changed: 1416 additions & 414 deletions

File tree

islanders-client/src/lib/components/Map.svelte

Lines changed: 94 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,54 @@
3232
generateTileNumber,
3333
generateThiefTile
3434
} from '$lib/SpriteGenerators';
35-
import { compareWorlds, getClosestPoint, getTwoClosestPoints } from './mapUtils';
35+
import { getClosestPoint, getTwoClosestPoints } from './mapUtils';
3636
import * as honeycombGrid from 'honeycomb-grid';
3737
import type { Grid as HoneycombGrid } from 'honeycomb-grid';
3838
const { defineHex, Grid, Orientation } = honeycombGrid;
3939
4040
let container: HTMLDivElement | undefined;
41+
let parentElement: HTMLElement | null = null;
4142
4243
const hexSize = 200;
4344
const tileHeight = 348;
4445
const tileWidth = 400;
4546
const lineWidth = 14;
47+
const tilePath = '/img/tilesets/';
48+
const tileStyle = 'realistic';
49+
const assetsToLoad = [
50+
// Tiles
51+
`${tilePath}${tileStyle}/clay.png`,
52+
`${tilePath}${tileStyle}/desert.png`,
53+
`${tilePath}${tileStyle}/grain.png`,
54+
`${tilePath}${tileStyle}/wood.png`,
55+
`${tilePath}${tileStyle}/stone.png`,
56+
`${tilePath}${tileStyle}/wool.png`,
57+
`${tilePath}${tileStyle}/ocean.png`,
58+
// Pieces
59+
'/img/pieces/house.png',
60+
'/img/pieces/city.png',
61+
'/img/pieces/thief.png',
62+
// Special
63+
`${tilePath}shared/scorch-with-thief.png`,
64+
// Harbors
65+
`${tilePath}${tileStyle}/woodharbor.png`,
66+
`${tilePath}${tileStyle}/woolharbor.png`,
67+
`${tilePath}${tileStyle}/grainharbor.png`,
68+
`${tilePath}${tileStyle}/clayharbor.png`,
69+
`${tilePath}${tileStyle}/stoneharbor.png`,
70+
`${tilePath}${tileStyle}/threetooneharbor.png`,
71+
// Numbers
72+
'/img/numbers/2.png',
73+
'/img/numbers/3.png',
74+
'/img/numbers/4.png',
75+
'/img/numbers/5.png',
76+
'/img/numbers/6.png',
77+
'/img/numbers/8.png',
78+
'/img/numbers/9.png',
79+
'/img/numbers/10.png',
80+
'/img/numbers/11.png',
81+
'/img/numbers/12.png'
82+
];
4683
4784
let app: Application | undefined;
4885
let worldContainer: Container | undefined;
@@ -78,13 +115,16 @@
78115
79116
// Store event handlers for cleanup
80117
let wheelHandler: ((event: WheelEvent) => void) | undefined;
118+
let assetsLoaded = false;
119+
let latestWorld: World | undefined;
120+
let loadingPromise: Promise<void> | undefined;
81121
82122
const currentPlayer: Player | undefined = $derived(
83123
gameState.world?.players.find((player: Player) => player.name === gameState.playerName)
84124
);
85125
86126
$effect(() => {
87-
drawMap(gameState.world);
127+
updateMap(gameState.world);
88128
});
89129
90130
$effect(() => {
@@ -114,8 +154,12 @@
114154
return;
115155
}
116156
117-
height = container.clientHeight / (window.devicePixelRatio || 1);
118-
width = container.clientWidth / (window.devicePixelRatio || 1);
157+
const target = parentElement ?? container;
158+
const ratio = window.devicePixelRatio || 1;
159+
height = target.clientHeight / ratio;
160+
width = target.clientWidth / ratio;
161+
container.style.width = `${target.clientWidth}px`;
162+
container.style.height = `${target.clientHeight}px`;
119163
app.renderer.resize(width, height);
120164
};
121165
@@ -317,7 +361,7 @@
317361
});
318362
};
319363
320-
const drawMap = (newWorld: World | undefined) => {
364+
const drawMap = (newWorld: World) => {
321365
if (!newWorld) {
322366
return;
323367
}
@@ -380,58 +424,53 @@
380424
pieceGraphics.addChild(pieceContainer);
381425
};
382426
427+
const updateMap = (newWorld: World | undefined) => {
428+
if (!newWorld) {
429+
return;
430+
}
431+
432+
latestWorld = newWorld;
433+
if (!assetsLoaded) {
434+
void ensureAssetsLoaded();
435+
return;
436+
}
437+
438+
drawMap(newWorld);
439+
};
440+
441+
const ensureAssetsLoaded = async () => {
442+
if (assetsLoaded) {
443+
return;
444+
}
445+
446+
if (!loadingPromise) {
447+
loadingPromise = Assets.load(assetsToLoad)
448+
.then(() => {
449+
assetsLoaded = true;
450+
if (latestWorld) {
451+
drawMap(latestWorld);
452+
}
453+
})
454+
.catch((error) => {
455+
console.error('Failed to load assets:', error);
456+
});
457+
}
458+
459+
await loadingPromise;
460+
};
461+
383462
const setupCanvas = async () => {
384463
if (!container) return;
385464
386-
height = container.clientHeight / (window.devicePixelRatio || 1);
387-
width = container.clientWidth / (window.devicePixelRatio || 1);
388-
389-
// Preload all assets for PixiJS v8
390-
const tilePath = '/img/tilesets/';
391-
const tileStyle = 'realistic';
392-
const assetsToLoad = [
393-
// Tiles
394-
`${tilePath}${tileStyle}/clay.png`,
395-
`${tilePath}${tileStyle}/desert.png`,
396-
`${tilePath}${tileStyle}/grain.png`,
397-
`${tilePath}${tileStyle}/wood.png`,
398-
`${tilePath}${tileStyle}/stone.png`,
399-
`${tilePath}${tileStyle}/wool.png`,
400-
`${tilePath}${tileStyle}/ocean.png`,
401-
// Pieces
402-
'/img/pieces/house.png',
403-
'/img/pieces/city.png',
404-
'/img/pieces/thief.png',
405-
// Special
406-
`${tilePath}shared/scorch-with-thief.png`,
407-
// Harbors
408-
`${tilePath}${tileStyle}/woodharbor.png`,
409-
`${tilePath}${tileStyle}/woolharbor.png`,
410-
`${tilePath}${tileStyle}/grainharbor.png`,
411-
`${tilePath}${tileStyle}/clayharbor.png`,
412-
`${tilePath}${tileStyle}/stoneharbor.png`,
413-
`${tilePath}${tileStyle}/threetooneharbor.png`,
414-
// Numbers
415-
'/img/numbers/2.png',
416-
'/img/numbers/3.png',
417-
'/img/numbers/4.png',
418-
'/img/numbers/5.png',
419-
'/img/numbers/6.png',
420-
'/img/numbers/8.png',
421-
'/img/numbers/9.png',
422-
'/img/numbers/10.png',
423-
'/img/numbers/11.png',
424-
'/img/numbers/12.png'
425-
];
426-
427-
// Load all assets
428-
try {
429-
console.log('Loading assets...');
430-
await Assets.load(assetsToLoad);
431-
console.log('Assets loaded successfully');
432-
} catch (error) {
433-
console.error('Failed to load assets:', error);
434-
}
465+
parentElement = container.parentElement;
466+
const target = parentElement ?? container;
467+
const ratio = window.devicePixelRatio || 1;
468+
height = target.clientHeight / ratio;
469+
width = target.clientWidth / ratio;
470+
container.style.width = `${target.clientWidth}px`;
471+
container.style.height = `${target.clientHeight}px`;
472+
473+
await ensureAssetsLoaded();
435474
436475
const appInstance = new Application();
437476
@@ -583,4 +622,4 @@
583622
});
584623
</script>
585624

586-
<div bind:this={container} class="h-full w-full bg-[#03518b]"></div>
625+
<div bind:this={container} class="h-full w-full flex-1 bg-[#03518b]"></div>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script lang="ts">
2+
import type { Player } from '../../../../islanders-shared/lib/Player';
3+
import { getPlayerColorAsHex } from '$lib/stores/game.svelte';
4+
5+
interface PlayerInformation {
6+
player?: Player;
7+
isActive?: boolean;
8+
subtitle?: string;
9+
size?: 'sm' | 'md';
10+
className?: string;
11+
}
12+
13+
const props = $props<PlayerInformation>();
14+
15+
const defaultColor = '#94a3b8';
16+
17+
const playerName = $derived(props.player?.name ?? '');
18+
const points = $derived(props.player?.points ?? 0);
19+
const color = $derived(
20+
props.player && playerName ? (getPlayerColorAsHex(playerName) ?? defaultColor) : defaultColor
21+
);
22+
const size = $derived(props.size ?? 'md');
23+
const subtitle = $derived(props.subtitle ?? (props.isActive ? 'Current turn' : undefined));
24+
</script>
25+
26+
<div
27+
class={`flex items-center gap-3 leading-tight ${
28+
size === 'sm' ? 'text-sm' : 'text-base'
29+
} ${props.isActive ? 'text-white' : 'text-white/90'} ${props.className ?? ''}`.trim()}
30+
>
31+
<span class="h-10 w-2 rounded-full" style={`background-color: ${color}`}></span>
32+
<div class="flex flex-col">
33+
<span class={`font-semibold ${size === 'sm' ? 'text-base' : 'text-lg'}`}>{playerName}</span>
34+
<span class="text-xs opacity-80">Points: {points}</span>
35+
{#if subtitle}
36+
<span class="text-[0.65rem] tracking-wide text-white/70 uppercase">{subtitle}</span>
37+
{/if}
38+
</div>
39+
</div>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { writable } from 'svelte/store';
2+
import type { ChatMessage } from '../../../../islanders-shared/lib/Shared';
3+
import { SocketActions } from '../../../../islanders-shared/lib/Shared';
4+
import { connect, getSocket } from './socket';
5+
import { gameState } from './game.svelte';
6+
7+
// Shape of a client-side chat entry (can extend later for system events)
8+
export interface ChatEntry extends ChatMessage {
9+
id: string; // unique client id
10+
ts: number; // timestamp (ms)
11+
pending?: boolean; // optimistic flag until echoed back
12+
self?: boolean; // whether authored by local player
13+
}
14+
15+
interface ChatState {
16+
messages: ChatEntry[];
17+
initialized: boolean;
18+
error?: string;
19+
}
20+
21+
const createInitial = (): ChatState => ({
22+
messages: [],
23+
initialized: false
24+
});
25+
26+
const { subscribe, update, set } = writable<ChatState>(createInitial());
27+
let listenersBound = false;
28+
29+
const MAX_MESSAGES = 250;
30+
31+
export const chatStore = { subscribe };
32+
33+
const appendMessage = (msg: ChatEntry) => {
34+
update((state) => {
35+
const next = [...state.messages, msg];
36+
// Keep within bound
37+
if (next.length > MAX_MESSAGES) next.splice(0, next.length - MAX_MESSAGES);
38+
return { ...state, messages: next };
39+
});
40+
};
41+
42+
// We augment outgoing chat messages with a clientId so we can reconcile the echo from server.
43+
// The server ignores extra fields, so this is safe.
44+
type OutgoingChatMessage = ChatMessage & { clientId?: string };
45+
46+
export const bindChat = () => {
47+
const socket = connect(); // ensures connection (will reuse existing)
48+
if (listenersBound) return socket;
49+
50+
socket.on(SocketActions.chat, (incoming: ChatMessage & { clientId?: string }) => {
51+
update((state) => {
52+
// Try to find optimistic entry by clientId first
53+
let idx = -1;
54+
if (incoming.clientId) {
55+
idx = state.messages.findIndex((m) => m.id === incoming.clientId);
56+
}
57+
// Fallback: match first pending with same user+text
58+
if (idx === -1) {
59+
idx = state.messages.findIndex(
60+
(m) => m.pending && m.text === incoming.text && m.user === incoming.user
61+
);
62+
}
63+
64+
if (idx !== -1) {
65+
const copy = [...state.messages];
66+
copy[idx] = { ...copy[idx], pending: false, ts: copy[idx].ts }; // keep original timestamp
67+
return { ...state, messages: copy };
68+
}
69+
70+
// Otherwise append as a new (non-duplicate) message
71+
const entry = {
72+
id: crypto.randomUUID(),
73+
ts: Date.now(),
74+
text: incoming.text,
75+
user: incoming.user,
76+
self: incoming.user === gameState.playerName,
77+
pending: false
78+
} as ChatEntry;
79+
const next = [...state.messages, entry];
80+
if (next.length > MAX_MESSAGES) next.splice(0, next.length - MAX_MESSAGES);
81+
return { ...state, messages: next };
82+
});
83+
});
84+
85+
socket.on('connect_error', (err: Error) => {
86+
update((s) => ({ ...s, error: err.message }));
87+
});
88+
89+
listenersBound = true;
90+
update((s) => ({ ...s, initialized: true }));
91+
return socket;
92+
};
93+
94+
export const sendChat = (text: string) => {
95+
const trimmed = text.trim();
96+
if (!trimmed) return;
97+
const playerName = gameState.playerName || 'Unknown';
98+
const socket = getSocket();
99+
if (!socket || !socket.connected) {
100+
update((s) => ({ ...s, error: 'Not connected' }));
101+
return;
102+
}
103+
const clientId = crypto.randomUUID();
104+
const optimistic: ChatEntry = {
105+
id: clientId,
106+
ts: Date.now(),
107+
text: trimmed,
108+
user: playerName,
109+
pending: true,
110+
self: true
111+
};
112+
appendMessage(optimistic);
113+
const outgoing: OutgoingChatMessage = { text: trimmed, user: playerName, clientId };
114+
socket.emit(SocketActions.chat, outgoing as ChatMessage);
115+
};
116+
117+
export const clearChat = () => set(createInitial());

0 commit comments

Comments
 (0)