feat(frontend): implement basic card rendering, drawing and playing

This commit is contained in:
2025-03-11 17:09:20 +01:00
parent 1597fb9b31
commit ee98d92f52
9 changed files with 447 additions and 14 deletions

View File

@ -0,0 +1,109 @@
<script lang="ts">
import type { Card } from "../../stores/sessionStore";
interface ClassicCard {
Symbol: string;
Color: string;
}
export let data: ClassicCard = { Color: "", Symbol: "" };
export let canUpdateCard: boolean = false;
export let updateCard: (newCard: Card) => void = () => {};
export let width: number;
export let height: number;
const COLOR_MAP: { [key: string]: string } = {
red: "0#990000",
green: "#009900",
blue: "#000099",
yellow: "#999900",
black: "#000000",
"": "#808080",
};
function selectColor(color: string) {
let newCard: ClassicCard = {
Color: color,
Symbol: data.Symbol,
};
updateCard(newCard);
}
</script>
<div class="card" style:background={COLOR_MAP[data.Color]} style:--width={`${width}px`} style:--height={`${height}px`}>
{#if data.Symbol.length <= 2}
<span class="symbol">{data.Symbol}</span>
{:else}
<span class="symbol large">{data.Symbol.split(":")[1].replace("_", " ")}</span>
{/if}
{#if data.Color == "black" && canUpdateCard}
<div class="select mt-5">
<div>
<button
class="bg-red-400"
aria-label="Red"
on:click={() => {
selectColor("red");
}}
></button>
<button
class="bg-green-400"
aria-label="Green"
on:click={() => {
selectColor("green");
}}
></button>
</div>
<div>
<button
class="bg-blue-400"
aria-label="Blue"
on:click={() => {
selectColor("blue");
}}
></button>
<button
class="bg-yellow-400"
aria-label="Yellow"
on:click={() => {
selectColor("yellow");
}}
></button>
</div>
</div>
{/if}
</div>
<style>
.card {
display: flex;
flex-direction: column;
width: var(--width);
height: var(--height);
padding: 5px;
border-radius: 8px;
}
.select button {
width: 20px;
height: 20px;
border-radius: 30px;
}
.card span {
text-align: left;
word-wrap: anywhere;
}
.card .symbol {
font-size: 25px;
text-transform: capitalize;
}
.card .symbol.large {
font-size: 17px;
}
button {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,91 @@
<script lang="ts">
import type { Card, CardInfoObj, CardPlayedObj } from "../../stores/sessionStore";
export let cardWidth: number;
export let cardHeight: number;
export let cards: CardInfoObj[] | CardPlayedObj[];
export let cardComponent;
export let click: (index: number) => void = () => {};
export let updateCard: (index: number, newCard: Card) => void = () => {};
export let canPlayCards: boolean = true;
export let canUpdateCards: boolean = false;
export let centerDistancePx: number;
export let maxRotationDeg: number;
export let fullwidth: boolean = false;
export let hoverOffset: number = canPlayCards ? 40 : 0;
let rotationDeg = 0;
$: rotationDeg = Math.min(maxRotationDeg, 1.5 * cards.length);
let maxOffset = [0, 0];
let offset = [0, 0];
$: if (centerDistancePx && rotationDeg) maxOffset = getCardOffset(getCardRotation(0, cards.length, maxRotationDeg));
$: if (centerDistancePx && rotationDeg) offset = getCardOffset(getCardRotation(0, cards.length, rotationDeg));
function getCardRotation(i: number, totalCards: number, maxRotationDeg: number): number {
if (totalCards == 1) return 0;
return -maxRotationDeg + (i / (totalCards - 1)) * 2 * maxRotationDeg;
}
function getCardOffset(angle: number): [number, number] {
let slope = Math.tan(angle * (Math.PI / 180));
let y = Math.sqrt(slope ** 2 + 1) * (centerDistancePx / (slope ** 2 + 1));
let x = slope * y;
return [x, centerDistancePx - y];
}
</script>
<div
class="cards relative flex justify-center box-content"
class:fullwidth
style:--height={`${maxOffset[1] + cardHeight}px`}
style:--width={`${-offset[0] * 2 + cardWidth}px`}
style:--hover-offset={`${hoverOffset}px`}
>
{#each cards as cardInfo, i}
{@const rotation = getCardRotation(i, cards.length, rotationDeg)}
{@const position = getCardOffset(rotation)}
<button
class="absolute card drop-shadow-lg"
disabled={!(cardInfo as CardInfoObj).CanPlay}
on:click={() => click(i)}
class:canPlayCards
style:--rotation={`${rotation}deg`}
style:--left={`${position[0]}px`}
style:--top={`${position[1]}px`}
>
<svelte:component
this={cardComponent}
width={cardWidth}
height={cardHeight}
data={cardInfo.Card}
canUpdateCard={canUpdateCards}
updateCard={(newCard: Card) => {
updateCard(i, newCard);
}}
/>
</button>
{/each}
</div>
<style>
.cards {
height: var(--height);
width: var(--width);
padding-top: var(--hover-offset);
}
.card {
transform: translate(var(--left), var(--top)) rotate(var(--rotation));
transition: 0.2s transform;
cursor: unset;
user-select: none;
}
.card.canPlayCards:enabled {
cursor: pointer;
}
.card.canPlayCards:disabled {
filter: brightness(0.4);
}
.card.canPlayCards:hover {
transform: translate(var(--left), var(--top)) rotate(var(--rotation)) translate(0px, calc(0px - var(--hover-offset)));
}
</style>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import type { PlayerObj, PlayerStateObj } from "../../stores/sessionStore";
import CardDisplay from "./CardDisplay.svelte";
export let player: PlayerObj;
export let state: PlayerStateObj | undefined;
export let cardComponent;
export let cardWidth: number;
export let cardHeight: number;
export let centerDistancePx;
export let maxRotationDeg;
export let rotationDeg;
</script>
{#if state}
<div class="opponentDisplay flex flex-col justify-center items-center" style:--rotation={`${rotationDeg}deg`}>
<p class:font-bold={state?.Active}>{player.Username}</p>
<CardDisplay
{cardComponent}
{cardWidth}
{cardHeight}
cards={Array(state?.NumCards).fill({ Card: undefined })}
canPlayCards={false}
canUpdateCards={false}
hoverOffset={0}
{centerDistancePx}
{maxRotationDeg}
/>
</div>
{/if}
<style>
.opponentDisplay {
transform: rotate(var(--rotation));
}
</style>

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import EndScreen from "./../components/Game/EndScreen.svelte"; import EndScreen from "./../views/Game/EndScreen.svelte";
import Main from "./../components/Game/Main.svelte"; import Main from "./../views/Game/Main.svelte";
import Lobby from "../components/Game/Lobby.svelte"; import Lobby from "../views/Game/Lobby.svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { GameState, sessionStore } from "../stores/sessionStore"; import { GameState, sessionStore } from "../stores/sessionStore";

View File

@ -1,4 +1,4 @@
import { writable, get } from "svelte/store"; import { writable, get, derived } from "svelte/store";
import { io, Socket } from "socket.io-client"; import { io, Socket } from "socket.io-client";
export enum GameState { export enum GameState {
@ -14,7 +14,7 @@ interface PlayerPermissionObj {
interface GameOptions {} interface GameOptions {}
interface PlayerObj { export interface PlayerObj {
PlayerId: string; PlayerId: string;
Username: string; Username: string;
Permissions: number; Permissions: number;
@ -26,19 +26,21 @@ interface SessionData {
joinCode: string | null; joinCode: string | null;
gameOptions: GameOptions; gameOptions: GameOptions;
players: Array<PlayerObj>; players: Array<PlayerObj>;
cardDeckId: string | null; cardDeckId: number | null;
gameState: GameState; gameState: GameState;
socket: Socket | null; socket: Socket | null;
connected: boolean; connected: boolean;
userId: string | null; userId: string | null;
messages: string[]; messages: any[];
sessionToken: string | null; sessionToken: string | null;
playedCards: CardPlayedObj[];
ownCards: CardInfoObj[];
playerStates: { [key: string]: PlayerStateObj };
} }
interface RoomInfoObj { interface RoomInfoObj {
RoomId: string; RoomId: string;
JoinCode: string; JoinCode: string;
TopCard: any; TopCard: Card;
GameState: GameState; GameState: GameState;
CardDeckId: number; CardDeckId: number;
Winner?: string; Winner?: string;
@ -51,8 +53,45 @@ interface StatusInfoObj {
Message: string; Message: string;
} }
export interface CardInfoObj {
CanPlay: boolean;
Card: Card;
}
interface OwnCardsObj {
Cards: CardInfoObj[];
}
export interface PlayerStateObj {
PlayerId: string;
NumCards: number;
Active: boolean;
}
export interface CardPlayedObj {
Card: Card;
CardIndex: number;
PlayedBy: string;
}
interface PlayedCardUpdateObj {
UpdatedBy: string;
Card: Card;
}
interface PlayCardReq {
CardIndex?: number;
CardData: any;
}
interface UpdatePlayedCardReq {
CardData: any;
}
export type Card = any;
class SessionManager { class SessionManager {
private store = writable<SessionData>({ store = writable<SessionData>({
roomId: null, roomId: null,
joinCode: null, joinCode: null,
gameState: -1, gameState: -1,
@ -64,6 +103,9 @@ class SessionManager {
userId: null, userId: null,
messages: [], messages: [],
sessionToken: null, sessionToken: null,
playedCards: [],
ownCards: [],
playerStates: {},
}); });
private socket: Socket | null = null; private socket: Socket | null = null;
@ -184,6 +226,10 @@ class SessionManager {
if (!userId) userId = storedSessionIds?.userId; if (!userId) userId = storedSessionIds?.userId;
} }
if (!sessionToken || !userId) {
console.warn("Socket connection requested without sessionToken or userId");
return;
}
if (this.socket) { if (this.socket) {
console.warn(`Socket already connected! Rejecting new connection to ${sessionToken}`); console.warn(`Socket already connected! Rejecting new connection to ${sessionToken}`);
return; return;
@ -202,6 +248,10 @@ class SessionManager {
this.socket?.on("disconnect", this.handleDisconnect.bind(this)); this.socket?.on("disconnect", this.handleDisconnect.bind(this));
this.socket?.on("Status", this.handleStatus.bind(this)); this.socket?.on("Status", this.handleStatus.bind(this));
this.socket?.on("RoomInfo", this.handleRoomInfo.bind(this)); this.socket?.on("RoomInfo", this.handleRoomInfo.bind(this));
this.socket?.on("OwnCards", this.handleOwnCardsUpdate.bind(this));
this.socket?.on("PlayerState", this.handlePlayerStateUpdate.bind(this));
this.socket?.on("CardPlayed", this.handleCardPlayed.bind(this));
this.socket?.on("PlayedCardUpdate", this.handlePlayedCardUpdate.bind(this));
this.socket?.on("error", this.handleError.bind(this)); this.socket?.on("error", this.handleError.bind(this));
} }
@ -248,6 +298,12 @@ class SessionManager {
cardDeckId: message.CardDeckId, cardDeckId: message.CardDeckId,
players: message.Players, players: message.Players,
})); }));
if (message.TopCard && get(this.store).playedCards.length == 0) {
this.store.update((state) => ({
...state,
playedCards: [{ Card: message.TopCard, CardIndex: -1, PlayedBy: "" }],
}));
}
this.saveJoinCode(); this.saveJoinCode();
this.store.update((state) => ({ this.store.update((state) => ({
...state, ...state,
@ -255,16 +311,78 @@ class SessionManager {
})); }));
} }
private handleOwnCardsUpdate(message: OwnCardsObj) {
this.store.update((state) => ({
...state,
ownCards: message.Cards,
}));
}
private handlePlayerStateUpdate(message: PlayerStateObj) {
get(this.store).playerStates[message.PlayerId] = message;
this.store.update((state) => state);
}
private handleCardPlayed(message: CardPlayedObj) {
this.store.update((state) => ({
...state,
playedCards: [...state.playedCards, message],
}));
}
private handlePlayedCardUpdate(message: PlayedCardUpdateObj) {
if (get(this.store).playedCards.length == 0) {
return;
}
get(this.store).playedCards[get(this.store).playedCards.length - 1].Card = message.Card;
this.store.update((state) => state);
}
private handleError(error: string) { private handleError(error: string) {
console.error("Socket error:", error); console.error("Socket error:", error);
} }
sendMessage(message: string) { get players(): PlayerObj[] {
if (this.socket && message.trim()) { return get(this.store).players;
this.socket.emit("event", message); }
get ownCards(): Card[] {
return get(this.store).ownCards;
}
get playedCards(): CardPlayedObj[] {
return get(this.store).playedCards;
}
getPlayerState(playerId: string): PlayerStateObj | undefined {
return get(this.store).playerStates[playerId];
}
sendMessage(event: string, message: string) {
if (this.socket) {
this.socket.emit(event, message);
} }
} }
drawCard() {
this.sendMessage("DrawCard", "");
}
playCard(cardIndex: number, data?: any) {
let request: PlayCardReq = {
CardIndex: cardIndex,
CardData: data,
};
this.sendMessage("PlayCard", JSON.stringify(request));
}
updatePlayedCard(data?: any) {
let request: UpdatePlayedCardReq = {
CardData: data,
};
this.sendMessage("UpdatePlayedCard", JSON.stringify(request));
}
leaveRoom() { leaveRoom() {
console.log("leave room"); console.log("leave room");
if (this.socket) { if (this.socket) {
@ -295,6 +413,9 @@ class SessionManager {
userId: null, userId: null,
messages: [], messages: [],
sessionToken: null, sessionToken: null,
playedCards: [],
ownCards: [],
playerStates: {},
}); });
window.history.replaceState({}, "", "/"); window.history.replaceState({}, "", "/");
} }

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import RenamePlayer from "./../RenamePlayer.svelte"; import RenamePlayer from "../../components/RenamePlayer.svelte";
import { Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell, TableSearch, Badge, Button, Modal, Popover, Tooltip } from "flowbite-svelte"; import { Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell, TableSearch, Badge, Button, Modal, Popover, Tooltip } from "flowbite-svelte";
import { CircleArrowOutUpLeft, Copy, AlertCircle, UserX, Play, TextCursorInput, Gamepad2 } from "lucide-svelte"; import { CircleArrowOutUpLeft, Copy, AlertCircle, UserX, Play, TextCursorInput, Gamepad2 } from "lucide-svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";

View File

@ -0,0 +1,75 @@
<script lang="ts">
import type { PlayerObj } from "../../stores/sessionStore";
import { derived, get } from "svelte/store";
import ClassicCard from "../../components/Cards/ClassicCard.svelte";
import CardDisplay from "../../components/Game/CardDisplay.svelte";
import { sessionStore } from "../../stores/sessionStore";
import OpponentDisplay from "../../components/Game/OpponentDisplay.svelte";
let maxRotationDeg = 20;
let centerDistancePx = 200;
let cardWidth = 100;
let cardHeight = 150;
let cardComponent = ClassicCard;
let opponents = derived(sessionStore.store, ($store) => $store.players.filter((e) => e.PlayerId != sessionStore.getUserId()));
let playerActive = derived(sessionStore.store, ($store) => ($store.playerStates[$store.userId ?? ""] ?? "").Active ?? false);
let perSide = 0;
$: perSide = Math.ceil($opponents.length / 3);
</script>
{#snippet OpponentCards(players: PlayerObj[], rotationDeg: number)}
{#each players as player}
<OpponentDisplay {cardComponent} {cardHeight} {cardWidth} {rotationDeg} {player} state={sessionStore.getPlayerState(player.PlayerId)} {centerDistancePx} {maxRotationDeg} />
{/each}
{/snippet}
<div class="game relative grid h-full grid-rows-[1fr_2fr_1fr]">
<div class="top">{@render OpponentCards($opponents.slice(0, perSide), 0)}</div>
<div class="middle grid grid-cols-[1fr_300px_1fr]">
<div class="left flex justify-center items-center">
{@render OpponentCards($opponents.slice(perSide, perSide * 2), -90)}
</div>
<div class="center grid grid-cols-2">
<div class="drawCard flex justify-center items-center">
<button class="draw" on:click={() => sessionStore.drawCard()}>
<svelte:component this={cardComponent} width={cardWidth} height={cardHeight} data={{ Color: "black", Symbol: "special:draw" }} />
</button>
</div>
<div class="cardStack flex justify-center items-center">
<CardDisplay
{cardComponent}
{cardHeight}
{cardWidth}
canPlayCards={false}
canUpdateCards={true}
updateCard={(_, data) => {
sessionStore.updatePlayedCard(data);
}}
cards={[{ Card: undefined, PlayedBy: "", CardIndex: -1 }, ...$sessionStore.playedCards]}
centerDistancePx={0}
maxRotationDeg={10}
/>
</div>
</div>
<div class="right flex justify-center items-center">
{@render OpponentCards($opponents.slice(perSide * 2, perSide * 3), 90)}
</div>
</div>
<div class="cardContainer w-full overflow-y-clip overflow-x-auto">
<div class="ownCards w-full min-w-min box-content flex items-end justify-center" class:opacity-60={!$playerActive}>
<CardDisplay
{cardHeight}
{cardWidth}
{cardComponent}
fullwidth
click={(i) => {
if (get(playerActive)) sessionStore.playCard(i);
}}
centerDistancePx={centerDistancePx * 3}
{maxRotationDeg}
cards={$sessionStore.ownCards}
/>
</div>
</div>
</div>