Files
scrum-poker-internal/resources/js/components/rooms/RoomTable.vue
T
2026-08-27 15:38:12 +02:00

268 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { useMediaQuery } from '@vueuse/core';
import { computed, ref } from 'vue';
import RoomModeratorControls from '@/components/rooms/RoomModeratorControls.vue';
import RoomSeat from '@/components/rooms/RoomSeat.vue';
import { usePaperBallToss } from '@/composables/usePaperBallToss';
import type {
PositionedRoomPlayer,
RoomTableProps,
SeatStyle,
} from '@/types/room';
const props = defineProps<RoomTableProps>();
const hasCompactHeight = useMediaQuery(
'(min-width: 768px) and (max-height: 800px)',
);
const gameBoard = ref<HTMLElement | null>(null);
const { activePaperBalls, hitPlayerIds, throwingAtPlayerId, throwPaperBall } =
usePaperBallToss({
roomId: () => props.room.id,
currentPlayerId: () => props.currentPlayer.id,
currentPlayerIsModerator: () => props.currentPlayer.is_moderator,
board: gameBoard,
});
const positionedPlayers = computed<PositionedRoomPlayer[]>(() => {
const currentPlayerIndex = props.room.players.findIndex(
(player) => player.id === props.currentPlayer.id,
);
const orderedPlayers =
currentPlayerIndex === -1
? props.room.players
: [
...props.room.players.slice(currentPlayerIndex),
...props.room.players.slice(0, currentPlayerIndex),
];
const playerCount = Math.max(orderedPlayers.length, 1);
return orderedPlayers.map((player, index) => {
const angle = Math.PI / 2 + (index * Math.PI * 2) / playerCount;
const horizontalRadius = 39;
const verticalRadius = hasCompactHeight.value ? 34 : 40;
const cardDistance = hasCompactHeight.value ? 72 : 96;
return {
...player,
isTopSeat: Math.sin(angle) < -0.25,
seatStyle: {
left: `${50 + Math.cos(angle) * horizontalRadius}%`,
top: `${48 + Math.sin(angle) * verticalRadius}%`,
'--card-x': `${-Math.cos(angle) * cardDistance}px`,
'--card-y': `${-Math.sin(angle) * cardDistance}px`,
} as SeatStyle,
};
});
});
const voteCount = computed(
() => props.room.players.filter((player) => player.has_voted).length,
);
</script>
<template>
<section class="mt-5 w-full md:mt-3 md:min-h-0 md:flex-1">
<div ref="gameBoard" class="game-board" aria-label="Scrum-Poker-Tisch">
<div class="poker-table" aria-hidden="true">
<div class="table-highlight"></div>
<div class="table-center">
<span
v-if="!room.revealed"
class="text-sm font-medium text-emerald-50/75"
>
{{ voteCount }} von {{ room.players.length }} haben
gewählt
</span>
<div v-else class="grid grid-cols-2 gap-8 text-emerald-50">
<div class="flex flex-col gap-1">
<span class="text-xs text-emerald-100/65">
Durchschnitt
</span>
<strong class="text-xl">
{{ room.statistics?.average ?? '–' }}
</strong>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-emerald-100/65">
Median
</span>
<strong class="text-xl">
{{ room.statistics?.median ?? '–' }}
</strong>
</div>
</div>
</div>
</div>
<div
v-for="paperBall in activePaperBalls"
:key="paperBall.id"
class="paper-ball-flight"
:style="paperBall.style"
aria-hidden="true"
>
<span class="paper-ball"></span>
</div>
<RoomSeat
v-for="player in positionedPlayers"
:key="player.id"
:player="player"
:current-player-id="currentPlayer.id"
:current-vote="currentPlayer.current_vote"
:cards="room.deck.cards"
:revealed="room.revealed"
:presenter-mode="presenterMode"
:is-paper-ball-hit="hitPlayerIds.includes(player.id)"
:throw-disabled="
!currentPlayer.is_moderator && throwingAtPlayerId !== null
"
@throw-paper-ball="throwPaperBall"
/>
<RoomModeratorControls
v-if="currentPlayer.is_moderator"
:room-id="room.id"
:revealed="room.revealed"
:has-votes="voteCount > 0"
/>
</div>
</section>
</template>
<style scoped>
.game-board {
position: relative;
width: 100%;
min-height: max(34rem, calc(100svh - 11rem));
isolation: isolate;
}
@media (min-width: 768px) {
.game-board {
height: 100%;
min-height: 0;
}
}
.poker-table {
position: absolute;
inset: 20% 15% 21%;
overflow: hidden;
border: 0.65rem solid #513b2d;
border-radius: 50%;
background:
radial-gradient(
circle at 50% 42%,
rgb(16 126 91 / 38%),
transparent 45%
),
linear-gradient(145deg, #136c54, #074c3d);
box-shadow:
inset 0 0 0 0.2rem rgb(255 255 255 / 8%),
inset 0 -1.5rem 3rem rgb(0 0 0 / 22%),
0 1.5rem 3rem rgb(0 0 0 / 35%);
}
.table-highlight {
position: absolute;
inset: 6%;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 50%;
}
.table-center {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 2rem;
text-align: center;
}
.paper-ball-flight {
position: absolute;
z-index: 40;
width: 1.65rem;
height: 1.65rem;
pointer-events: none;
will-change: transform;
animation: paper-ball-flight 780ms linear forwards;
}
.paper-ball {
display: block;
width: 100%;
height: 100%;
border: 1px solid rgb(100 116 139 / 65%);
border-radius: 48% 42% 54% 39%;
background:
linear-gradient(
35deg,
transparent 44%,
rgb(100 116 139 / 35%) 46% 51%,
transparent 53%
),
linear-gradient(
120deg,
transparent 35%,
rgb(148 163 184 / 45%) 37% 42%,
transparent 44%
),
#e2e8f0;
}
@keyframes paper-ball-flight {
0% {
transform: translate(-50%, -50%) translate(0, 0) rotate(0deg)
scale(0.72);
}
25% {
transform: translate(-50%, -50%)
translate(var(--travel-quarter-x), var(--travel-quarter-y))
rotate(195deg) scale(0.92);
}
50% {
transform: translate(-50%, -50%)
translate(var(--travel-mid-x), var(--travel-mid-y)) rotate(390deg)
scale(1.08);
}
75% {
transform: translate(-50%, -50%)
translate(
var(--travel-three-quarter-x),
var(--travel-three-quarter-y)
)
rotate(585deg) scale(1);
}
100% {
transform: translate(-50%, -50%)
translate(var(--travel-x), var(--travel-y)) rotate(780deg)
scale(0.82);
}
}
@media (max-width: 640px) {
.game-board {
min-height: max(28rem, calc(100svh - 10rem));
}
.poker-table {
inset: 24% 17% 23%;
border-width: 0.45rem;
}
}
@media (prefers-reduced-motion: reduce) {
.paper-ball-flight {
animation-duration: 1ms;
}
}
</style>