This commit is contained in:
2026-09-01 11:59:00 +02:00
parent b2c1d67283
commit 9cf127b16d
10 changed files with 509 additions and 33 deletions
+272 -17
View File
@@ -1,14 +1,181 @@
<script setup lang="ts">
import { CircleUserRound, Crown } from '@lucide/vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import type { CSSProperties } from 'vue';
import type { RoomSeatProps } from '@/types/room';
const props = defineProps<RoomSeatProps>();
const emit = defineEmits<{
throwPaperBall: [playerId: number];
throwPaperBall: [playerId: number, chargePercentage: number];
}>();
const MAX_CHARGE_PERCENTAGE = 300;
const CHARGE_DURATION = 3_000;
const chargePercentage = ref(0);
const chargeStartedAt = ref<number | null>(null);
let chargeAnimationFrame: number | null = null;
let ignoreClickTimer: number | null = null;
let ignoreNextClick = false;
const isCharging = computed(() => chargeStartedAt.value !== null);
const seatStyle = computed<CSSProperties>(() => ({
...props.player.seatStyle,
'--paper-ball-knockback-x': `${props.paperBallKnockback.x}px`,
'--paper-ball-knockback-y': `${props.paperBallKnockback.y}px`,
}));
function updateCharge(timestamp: number): void {
if (chargeStartedAt.value === null) {
return;
}
chargePercentage.value = Math.min(
MAX_CHARGE_PERCENTAGE,
((timestamp - chargeStartedAt.value) / CHARGE_DURATION) *
MAX_CHARGE_PERCENTAGE,
);
if (chargePercentage.value < MAX_CHARGE_PERCENTAGE) {
chargeAnimationFrame = window.requestAnimationFrame(updateCharge);
} else {
chargeAnimationFrame = null;
}
}
function startCharging(): void {
if (props.throwDisabled || chargeStartedAt.value !== null) {
return;
}
chargePercentage.value = 0;
chargeStartedAt.value = performance.now();
chargeAnimationFrame = window.requestAnimationFrame(updateCharge);
}
function stopChargeAnimation(): void {
if (chargeAnimationFrame !== null) {
window.cancelAnimationFrame(chargeAnimationFrame);
chargeAnimationFrame = null;
}
}
function resetCharge(): void {
stopChargeAnimation();
chargeStartedAt.value = null;
chargePercentage.value = 0;
}
function finishCharging(): void {
if (chargeStartedAt.value === null) {
return;
}
updateCharge(performance.now());
const releasedChargePercentage = Math.min(
MAX_CHARGE_PERCENTAGE,
Math.floor(chargePercentage.value),
);
resetCharge();
emit('throwPaperBall', props.player.id, releasedChargePercentage);
ignoreNextClick = true;
if (ignoreClickTimer !== null) {
window.clearTimeout(ignoreClickTimer);
}
ignoreClickTimer = window.setTimeout(() => {
ignoreNextClick = false;
ignoreClickTimer = null;
});
}
function startPointerCharge(event: PointerEvent): void {
if (event.button !== 0) {
return;
}
(event.currentTarget as HTMLButtonElement).setPointerCapture(
event.pointerId,
);
startCharging();
}
function finishPointerCharge(event: PointerEvent): void {
const button = event.currentTarget as HTMLButtonElement;
if (button.hasPointerCapture(event.pointerId)) {
button.releasePointerCapture(event.pointerId);
}
finishCharging();
}
function cancelPointerCharge(): void {
resetCharge();
}
function handleKeyDown(event: KeyboardEvent): void {
if (!['Enter', ' '].includes(event.key)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (!event.repeat) {
startCharging();
}
}
function handleKeyUp(event: KeyboardEvent): void {
if (!['Enter', ' '].includes(event.key)) {
return;
}
event.preventDefault();
event.stopPropagation();
finishCharging();
}
function handleClick(): void {
if (ignoreNextClick) {
ignoreNextClick = false;
return;
}
emit('throwPaperBall', props.player.id, 0);
}
function chargeSegmentProgress(segment: number): number {
return Math.min(100, Math.max(0, chargePercentage.value - segment * 100));
}
function chargeColor(): string {
if (chargePercentage.value > 200) {
return '#ef4444';
}
if (chargePercentage.value > 100) {
return '#f97316';
}
return '#22c55e';
}
onBeforeUnmount(() => {
stopChargeAnimation();
if (ignoreClickTimer !== null) {
window.clearTimeout(ignoreClickTimer);
}
});
function cardLabel(value: string | null): string {
if (value === null) {
return '–';
@@ -30,7 +197,7 @@ function playerVote(): string | null {
<article
class="player-seat"
:class="{ 'is-paper-ball-hit': isPaperBallHit }"
:style="player.seatStyle"
:style="seatStyle"
:data-player-id="player.id"
>
<div
@@ -98,12 +265,52 @@ function playerVote(): string | null {
v-if="!isCurrentPlayer()"
type="button"
class="throw-paper-ball"
:class="{ 'is-charging': isCharging }"
:disabled="throwDisabled"
:aria-label="`Papierknäuel auf ${player.name} werfen`"
:title="`Papierknäuel auf ${player.name} werfen`"
@click.stop="emit('throwPaperBall', player.id)"
:aria-label="`Papierknäuel auf ${player.name} werfen – gedrückt halten zum Aufladen${isCharging ? `, ${Math.floor(chargePercentage)} Prozent geladen` : ''}`"
:aria-pressed="isCharging"
:title="`Papierknäuel auf ${player.name} werfen – gedrückt halten zum Aufladen`"
@pointerdown.stop.prevent="startPointerCharge"
@pointerup.stop.prevent="finishPointerCharge"
@pointercancel="cancelPointerCharge"
@keydown="handleKeyDown"
@keyup="handleKeyUp"
@blur="cancelPointerCharge"
@click.stop.prevent="handleClick"
>
<span class="paper-ball-mini" aria-hidden="true"></span>
<svg
v-if="isCharging"
class="charge-rings"
viewBox="0 0 48 48"
aria-hidden="true"
>
<g
v-for="segment in 3"
v-show="chargePercentage > (segment - 1) * 100"
:key="segment"
>
<circle
class="charge-ring-track"
cx="24"
cy="24"
:r="8 + segment * 4"
/>
<circle
class="charge-ring"
cx="24"
cy="24"
:r="8 + segment * 4"
pathLength="100"
:stroke="chargeColor()"
:stroke-dashoffset="
100 - chargeSegmentProgress(segment - 1)
"
/>
</g>
</svg>
<span class="throw-paper-ball-surface" aria-hidden="true">
<span class="paper-ball-mini"></span>
</span>
</button>
</article>
</template>
@@ -113,7 +320,11 @@ function playerVote(): string | null {
position: absolute;
z-index: 2;
width: 8rem;
transform: translate(-50%, -50%);
transform: translate(-50%, -50%)
translate(
var(--paper-ball-knockback-x, 0),
var(--paper-ball-knockback-y, 0)
);
text-align: center;
}
@@ -127,23 +338,22 @@ function playerVote(): string | null {
.throw-paper-ball {
position: absolute;
top: 0.65rem;
right: 0.35rem;
top: 0.15rem;
right: -0.15rem;
z-index: 8;
display: grid;
width: 2rem;
height: 2rem;
width: 3rem;
height: 3rem;
place-items: center;
border: 1px solid #64748b;
border-radius: 0.5rem;
background: #334155;
border: 0;
background: transparent;
opacity: 0;
transform: translateY(0.25rem) rotate(-8deg);
pointer-events: none;
touch-action: none;
transition:
opacity 140ms ease,
transform 140ms ease,
background-color 140ms ease;
transform 140ms ease;
}
.player-seat:hover .throw-paper-ball,
@@ -153,9 +363,14 @@ function playerVote(): string | null {
pointer-events: auto;
}
.throw-paper-ball:hover:not(:disabled) .throw-paper-ball-surface,
.throw-paper-ball:focus-visible .throw-paper-ball-surface,
.throw-paper-ball.is-charging .throw-paper-ball-surface {
background: #475569;
}
.throw-paper-ball:hover:not(:disabled),
.throw-paper-ball:focus-visible {
background: #475569;
transform: translateY(-0.1rem) rotate(4deg);
}
@@ -170,6 +385,46 @@ function playerVote(): string | null {
pointer-events: none;
}
.throw-paper-ball-surface {
position: relative;
z-index: 1;
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 1px solid #64748b;
border-radius: 0.5rem;
background: #334155;
transition: background-color 140ms ease;
}
.charge-rings {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
filter: drop-shadow(0 0 0.2rem rgb(15 23 42 / 75%));
}
.charge-ring-track,
.charge-ring {
fill: none;
stroke-width: 2.25;
}
.charge-ring-track {
stroke: rgb(148 163 184 / 32%);
}
.charge-ring {
transform: rotate(-90deg);
transform-origin: 24px 24px;
stroke-linecap: round;
stroke-dasharray: 100;
transition: stroke 100ms linear;
}
.paper-ball-mini {
display: block;
width: 0.95rem;
+30 -6
View File
@@ -17,12 +17,17 @@ const hasCompactHeight = useMediaQuery(
);
const gameBoard = ref<HTMLElement | null>(null);
const { activePaperBalls, hitPlayerIds, throwingAtPlayerId, throwPaperBall } =
usePaperBallToss({
roomId: () => props.room.id,
currentPlayerId: () => props.currentPlayer.id,
board: gameBoard,
});
const {
activePaperBalls,
hitPlayerIds,
paperBallKnockbacks,
throwingAtPlayerId,
throwPaperBall,
} = usePaperBallToss({
roomId: () => props.room.id,
currentPlayerId: () => props.currentPlayer.id,
board: gameBoard,
});
const positionedPlayers = computed<PositionedRoomPlayer[]>(() => {
const currentPlayerIndex = props.room.players.findIndex(
@@ -99,6 +104,7 @@ const voteCount = computed(
v-for="paperBall in activePaperBalls"
:key="paperBall.id"
class="paper-ball-flight"
:class="`paper-ball-level-${paperBall.chargeLevel}`"
:style="paperBall.style"
aria-hidden="true"
>
@@ -115,6 +121,9 @@ const voteCount = computed(
:revealed="room.revealed"
:presenter-mode="presenterMode"
:is-paper-ball-hit="hitPlayerIds.includes(player.id)"
:paper-ball-knockback="
paperBallKnockbacks[player.id] ?? { x: 0, y: 0 }
"
:throw-disabled="throwingAtPlayerId !== null"
@throw-paper-ball="throwPaperBall"
/>
@@ -189,6 +198,21 @@ const voteCount = computed(
animation: paper-ball-flight 780ms linear forwards;
}
.paper-ball-flight.paper-ball-level-1 {
width: 2.05rem;
height: 2.05rem;
}
.paper-ball-flight.paper-ball-level-2 {
width: 2.5rem;
height: 2.5rem;
}
.paper-ball-flight.paper-ball-level-3 {
width: 3rem;
height: 3rem;
}
.paper-ball {
display: block;
width: 100%;