mirror of
https://github.com/HexCardGames/HexDeck.git
synced 2025-09-09 04:38:38 +02:00
feat(frontend): implement basic card rendering, drawing and playing
This commit is contained in:
109
frontend/src/components/Cards/ClassicCard.svelte
Normal file
109
frontend/src/components/Cards/ClassicCard.svelte
Normal 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>
|
91
frontend/src/components/Game/CardDisplay.svelte
Normal file
91
frontend/src/components/Game/CardDisplay.svelte
Normal 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>
|
@ -1,267 +0,0 @@
|
||||
<script lang="ts">
|
||||
import RenamePlayer from "./../RenamePlayer.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 { _ } from "svelte-i18n";
|
||||
import { GameState, sessionStore } from "../../stores/sessionStore";
|
||||
import { toggleLobbyOverlay } from "../../stores/gameStore";
|
||||
|
||||
let copied = false;
|
||||
let showLeaveModal = false;
|
||||
|
||||
$: players = $sessionStore.players;
|
||||
|
||||
let searchQuery = "";
|
||||
let rename_player = "";
|
||||
let showRenameModal = false;
|
||||
let kick_player = "";
|
||||
let showKickModal = false;
|
||||
|
||||
function filteredPlayers() {
|
||||
return players.filter((player) => player.Username.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}
|
||||
|
||||
function insert(str: string, index: number, value: string) {
|
||||
return str.slice(0, index) + value + str.slice(index);
|
||||
}
|
||||
|
||||
function copyGameCodeToClipboard() {
|
||||
navigator.clipboard.writeText(insert($sessionStore.joinCode || "000000", 3, "-")).then(() => {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function copyGameLinkToClipboard() {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/Game?join=${$sessionStore.joinCode}`).then(() => {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function leaveRoom() {
|
||||
sessionStore.leaveRoom();
|
||||
showLeaveModal = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Modal: Confirm Leave Room -->
|
||||
<Modal bind:open={showLeaveModal} size="md" backdropClass="fixed inset-0 z-40 bg-gray-900 bg-black/50 dark:bg-black/80 backdrop-opacity-50" autoclose outsideclose>
|
||||
<div class="text-center">
|
||||
<AlertCircle class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" />
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
|
||||
{$_("lobby.confirm_leave_message")}
|
||||
</h3>
|
||||
<Button on:click={() => (showLeaveModal = false)} color="alternative" class="hover:text-dark hover:bg-gray-100">{$_("lobby.cancel")}</Button>
|
||||
<Button on:click={leaveRoom} color="red" class="me-2">{$_("lobby.confirm_leave")}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal: Rename Player -->
|
||||
<Modal bind:open={showRenameModal} size="md" backdropClass="fixed inset-0 z-40 bg-gray-900 bg-black/50 dark:bg-black/80 backdrop-opacity-50" autoclose outsideclose>
|
||||
<div class="text-center">
|
||||
<TextCursorInput class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" />
|
||||
<RenamePlayer playerId={rename_player} />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal: Confirm Kick Player -->
|
||||
<Modal bind:open={showKickModal} size="md" backdropClass="fixed inset-0 z-40 bg-gray-900 bg-black/50 dark:bg-black/80 backdrop-opacity-50" autoclose outsideclose>
|
||||
<div class="text-center">
|
||||
<UserX class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" />
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
|
||||
{$_("lobby.confirm_kick_player_message", {
|
||||
values: { player_name: sessionStore.getUser(kick_player)?.Username || "Name not found" },
|
||||
})}
|
||||
</h3>
|
||||
<Button on:click={() => (showLeaveModal = false)} color="alternative" class="hover:text-dark hover:bg-gray-100">{$_("lobby.cancel")}</Button>
|
||||
<Button
|
||||
on:click={() => {
|
||||
sessionStore.kickPlayer(kick_player);
|
||||
}}
|
||||
color="red"
|
||||
class="me-2">{$_("lobby.confirm_kick_player")}</Button
|
||||
>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Leave Room Button -->
|
||||
<Button
|
||||
color="none"
|
||||
class="sm:absolute m-2 border-2 border-gray-500 dark:border-gray-300 hover:bg-gray-500 dark:hover:bg-gray-300 hover:text-white dark:hover:text-black rounded-full text-gray-500 dark:text-gray-300"
|
||||
on:click={() => {
|
||||
showLeaveModal = true;
|
||||
}}
|
||||
>
|
||||
<CircleArrowOutUpLeft class="mr-2" />
|
||||
<span>{$_("lobby.leave_game")}</span>
|
||||
</Button>
|
||||
|
||||
<!-- Return to game Button -->
|
||||
{#if sessionStore.getState().gameState !== GameState.Lobby}
|
||||
<Button
|
||||
color="none"
|
||||
class="sm:absolute m-2 right-0 border-2 border-gray-500 dark:border-gray-300 hover:bg-gray-500 dark:hover:bg-gray-300 hover:text-white dark:hover:text-black rounded-full text-gray-500 dark:text-gray-300"
|
||||
on:click={() => {
|
||||
toggleLobbyOverlay();
|
||||
}}
|
||||
>
|
||||
<span>{$_("lobby.return_to_game")}</span>
|
||||
<Gamepad2 class="ml-2" />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Game Status -->
|
||||
<div class="text-center p-6 w-full">
|
||||
<span
|
||||
>{$_("game_status.game_status")}
|
||||
<Badge color="dark">
|
||||
{$_(`game_status.${GameState[$sessionStore.gameState].toLowerCase()}`)}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- TODO Grid not fully responsive -->
|
||||
<div class="grid md:grid-flow-col grid-flow-row justify-center mt-6 mb-2 gap-4">
|
||||
<!-- Rename (This) Player -->
|
||||
{#if sessionStore.isConnected()}
|
||||
<RenamePlayer />
|
||||
{/if}
|
||||
|
||||
<!-- Copy Join Code Button -->
|
||||
<!-- TODO add Streamer mode (hide room code) here -->
|
||||
{#if sessionStore.getState().gameState === GameState.Lobby}
|
||||
<Button
|
||||
id="b1"
|
||||
type="button"
|
||||
class="w-xs mx-auto text-dark bg-primary-200 dark:bg-primary-900 hover:bg-primary-200 dark:hover:bg-primary-900 backdrop-blur-lg border border-black/20 dark:border-white/20 shadow-lg p-4 rounded-2xl flex items-center justify-between transition-all cursor-pointer"
|
||||
on:click={() => {
|
||||
copyGameCodeToClipboard();
|
||||
}}
|
||||
>
|
||||
<div class="grid justify-items-start">
|
||||
<span class="text-sm">{$_("lobby.room_join_code")}</span>
|
||||
<div class="relative">
|
||||
<span class="text-xl font-semibold tracking-widest select-none transition-opacity duration-300 opacity-0" class:opacity-100={!copied}
|
||||
>{insert($sessionStore.joinCode || "000000", 3, "-")}
|
||||
</span>
|
||||
<span class="absolute left-0 text-xl font-semibold tracking-widest select-none transition-opacity duration-300 opacity-0" class:opacity-100={copied}>
|
||||
{$_("lobby.copied")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Copy />
|
||||
</Button>
|
||||
<Popover class="text-sm max-w-screen font-light z-100" triggeredBy="#b1" placement="bottom">
|
||||
<div class="grid gap-2">
|
||||
<Button
|
||||
on:click={() => {
|
||||
copyGameCodeToClipboard();
|
||||
}}
|
||||
>
|
||||
{$_("lobby.copy_code")}
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => {
|
||||
copyGameLinkToClipboard();
|
||||
}}
|
||||
>
|
||||
{$_("lobby.copy_join_link")}
|
||||
</Button>
|
||||
{#if sessionStore.getPlayerPermissions().isHost}
|
||||
<Button on:click={() => {}}>
|
||||
{$_("lobby.regenerate_join_code")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
<!-- Start game button -->
|
||||
{#if sessionStore.getPlayerPermissions().isHost && sessionStore.getState().gameState === GameState.Lobby}
|
||||
<Button
|
||||
class="w-xs mx-auto text-dark bg-green-200 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-900 backdrop-blur-lg border border-black/20 dark:border-white/20 shadow-lg p-4 rounded-2xl flex items-center justify-between transition-all cursor-pointer"
|
||||
on:click={() => {
|
||||
sessionStore.startGame();
|
||||
}}
|
||||
>
|
||||
<div class="grid justify-items-start">
|
||||
<div class="relative">
|
||||
<span class="text-xl font-semibold tracking-widest select-none transition-opacity duration-300">{$_("lobby.start_game")} </span>
|
||||
</div>
|
||||
</div>
|
||||
<Play />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if players.length > 5}
|
||||
<!-- Search Bar -->
|
||||
<TableSearch bind:inputValue={searchQuery} placeholder={$_("lobby.search_player")} />
|
||||
{/if}
|
||||
|
||||
<!-- Players Table -->
|
||||
<Table striped hoverable noborder class="mb-16">
|
||||
<TableHead>
|
||||
<TableHeadCell class="cursor-pointer flex items-center">
|
||||
{$_("lobby.player_name")}
|
||||
</TableHeadCell>
|
||||
<TableHeadCell>{$_("lobby.status")}</TableHeadCell>
|
||||
</TableHead>
|
||||
|
||||
<TableBody tableBodyClass="divide-y">
|
||||
{#each filteredPlayers() as player}
|
||||
<TableBodyRow class="!bg-black/2 hover:!bg-black/4 dark:!bg-white/20 dark:hover:!bg-white/30">
|
||||
<TableBodyCell>
|
||||
{player.Username}
|
||||
{#if sessionStore.isCurrentPlayer(player.PlayerId)}
|
||||
<Badge color="purple" class="ml-1">{$_("lobby.you")}</Badge>
|
||||
{/if}
|
||||
{#if sessionStore.getPlayerPermissions(player.PlayerId).isHost}
|
||||
<Badge color="blue" class="ml-1">{$_("lobby.host")}</Badge>
|
||||
{/if}
|
||||
</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
{#if player.IsConnected}
|
||||
<Badge color="green">{$_(`player_status.connected`)}</Badge>
|
||||
{:else}
|
||||
<Badge color="yellow">{$_(`player_status.disconnected`)}</Badge>
|
||||
{/if}
|
||||
</TableBodyCell>
|
||||
<!-- Can kick and rename player -->
|
||||
{#if sessionStore.getPlayerPermissions().isHost}
|
||||
<TableBodyCell>
|
||||
<!-- kick player -->
|
||||
<Button
|
||||
outline={true}
|
||||
color="alternative"
|
||||
class="p-2! text-red-800 hover:bg-red-500"
|
||||
size="lg"
|
||||
on:click={() => {
|
||||
showKickModal = true;
|
||||
kick_player = player.PlayerId;
|
||||
}}
|
||||
>
|
||||
<UserX class="w-7 h-7" />
|
||||
</Button>
|
||||
<Tooltip type="auto">{$_("lobby.kick_player")}</Tooltip>
|
||||
<!-- rename player -->
|
||||
<Button
|
||||
outline={true}
|
||||
color="alternative"
|
||||
class="p-2! text-blue-800 hover:bg-blue-500"
|
||||
size="lg"
|
||||
on:click={() => {
|
||||
showRenameModal = true;
|
||||
rename_player = player.PlayerId;
|
||||
}}
|
||||
>
|
||||
<TextCursorInput class="w-7 h-7" />
|
||||
</Button>
|
||||
<Tooltip type="auto">{$_("lobby.rename_player")}</Tooltip>
|
||||
</TableBodyCell>
|
||||
{/if}
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
37
frontend/src/components/Game/OpponentDisplay.svelte
Normal file
37
frontend/src/components/Game/OpponentDisplay.svelte
Normal 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>
|
Reference in New Issue
Block a user