Switch to Svelte
This commit is contained in:
437
frontend/src/components/UpkeepPhase.svelte
Normal file
437
frontend/src/components/UpkeepPhase.svelte
Normal file
@@ -0,0 +1,437 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { getSuggestion } from '../lib/suggestions';
|
||||
|
||||
export let state;
|
||||
|
||||
let voting = false;
|
||||
let readying = false;
|
||||
let confirming = false;
|
||||
|
||||
let selectedVoteId = '';
|
||||
|
||||
// Helpers
|
||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||
|
||||
// Drag & Drop State
|
||||
let keepCards = [];
|
||||
let discardCards = [];
|
||||
let dragNDropInitialized = false;
|
||||
|
||||
// Ghost & Re-rolling fate states
|
||||
let isRolling = false;
|
||||
let reRollDetails = {
|
||||
avatar_look: '',
|
||||
avatar_smell: '',
|
||||
first_words: '',
|
||||
good_at_math: ''
|
||||
};
|
||||
let rollingError = '';
|
||||
|
||||
async function becomeGhost() {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReRoll() {
|
||||
if (!reRollDetails.avatar_look.trim() ||
|
||||
!reRollDetails.avatar_smell.trim() ||
|
||||
!reRollDetails.first_words.trim() ||
|
||||
!reRollDetails.good_at_math.trim()) {
|
||||
rollingError = 'Please fill out all character details.';
|
||||
return;
|
||||
}
|
||||
rollingError = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/roll-new-character`, 'POST', reRollDetails);
|
||||
isRolling = false;
|
||||
// Clear inputs
|
||||
reRollDetails = {
|
||||
avatar_look: '',
|
||||
avatar_smell: '',
|
||||
first_words: '',
|
||||
good_at_math: ''
|
||||
};
|
||||
} catch(e) {
|
||||
rollingError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (state && state.game.phase === 'deep_upkeep') {
|
||||
if (!dragNDropInitialized) {
|
||||
keepCards = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||
discardCards = [];
|
||||
dragNDropInitialized = true;
|
||||
}
|
||||
} else {
|
||||
dragNDropInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
let draggedCardCode = null;
|
||||
|
||||
function handleDragStart(e, card) {
|
||||
draggedCardCode = card;
|
||||
e.dataTransfer.setData("text/plain", card);
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleDropToKeep(e) {
|
||||
e.preventDefault();
|
||||
const card = e.dataTransfer.getData("text/plain") || draggedCardCode;
|
||||
if (card && !keepCards.includes(card)) {
|
||||
discardCards = discardCards.filter(c => c !== card);
|
||||
keepCards = [...keepCards, card];
|
||||
}
|
||||
}
|
||||
|
||||
function handleDropToDiscard(e) {
|
||||
e.preventDefault();
|
||||
const card = e.dataTransfer.getData("text/plain") || draggedCardCode;
|
||||
if (card && !discardCards.includes(card)) {
|
||||
keepCards = keepCards.filter(c => c !== card);
|
||||
discardCards = [...discardCards, card];
|
||||
}
|
||||
}
|
||||
|
||||
function moveToDiscard(card) {
|
||||
keepCards = keepCards.filter(c => c !== card);
|
||||
if (!discardCards.includes(card)) {
|
||||
discardCards = [...discardCards, card];
|
||||
}
|
||||
}
|
||||
|
||||
function moveToKeep(card) {
|
||||
discardCards = discardCards.filter(c => c !== card);
|
||||
if (!keepCards.includes(card)) {
|
||||
keepCards = [...keepCards, card];
|
||||
}
|
||||
}
|
||||
|
||||
$: maxHandSize = state.player.rank + 1;
|
||||
$: isOverLimit = keepCards.length > maxHandSize;
|
||||
|
||||
async function submitVote() {
|
||||
if (!selectedVoteId) return;
|
||||
voting = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-vote`, 'POST', {
|
||||
nominated_id: selectedVoteId
|
||||
});
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
voting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setReady() {
|
||||
readying = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/ready-next`, 'POST');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
readying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRefresh() {
|
||||
confirming = true;
|
||||
try {
|
||||
let discards = JSON.stringify(discardCards);
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/confirm-refresh`, 'POST', {
|
||||
discard_cards: discards
|
||||
});
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
confirming = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="between-scenes-view text-center">
|
||||
<h2>Between Scenes (Scene #{state.game.current_scene_number} Concluded)</h2>
|
||||
<p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p>
|
||||
|
||||
{#if state.player.is_dead && !state.player.is_ghost}
|
||||
<!-- Death Fate Panel -->
|
||||
<div class="card glass-panel death-fate-card" style="border: 2px solid var(--red-suit); background: rgba(255, 42, 95, 0.05); padding: 2.5rem; margin: 2rem auto; max-width: 650px; border-radius: 12px; box-shadow: 0 0 20px rgba(255,42,95,0.15); text-align: center;">
|
||||
<h2 style="color: var(--red-suit); font-family: var(--font-heading); margin-bottom: 1rem; font-size: 2rem;">💀 Your Pi-Rat Has Died!</h2>
|
||||
<p class="description" style="font-size: 1.15rem; color: var(--text-primary); margin-bottom: 2rem; line-height: 1.6;">
|
||||
Your story arc is complete! You went out in a blaze of glory (or retired honorably). You must now choose how your journey continues:
|
||||
</p>
|
||||
|
||||
{#if !isRolling}
|
||||
<div style="display: flex; gap: 1.5rem; justify-content: center; flex-wrap: wrap;">
|
||||
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost} style="border: 1px solid var(--text-muted); font-size: 1.1rem; padding: 0.8rem 2rem;">
|
||||
👻 Remain as a Ghost
|
||||
</button>
|
||||
<button class="btn btn-primary btn-large glow-effect" on:click={() => isRolling = true} style="background: var(--gold); border: 1px solid var(--gold); color: black; font-size: 1.1rem; padding: 0.8rem 2rem;">
|
||||
🐀 Roll a New Character
|
||||
</button>
|
||||
</div>
|
||||
<p class="info-text" style="margin-top: 1.5rem; font-style: italic; color: var(--text-muted);">
|
||||
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh with a randomized starting Rank between 1 and 3.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="creation-form-wrapper" style="text-align: left; background: rgba(0,0,0,0.2); padding: 1.5rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.05);">
|
||||
<h3 style="color: var(--gold); font-family: var(--font-heading); margin-bottom: 1.5rem; border-bottom: 1px solid rgba(212,175,55,0.2); padding-bottom: 0.5rem;">Create Your New Recruit</h3>
|
||||
|
||||
{#if rollingError}
|
||||
<div class="alert alert-danger" style="margin-bottom: 1.5rem;">{rollingError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1.25rem;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What does your Pi-Rat look like?</label>
|
||||
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" placeholder="e.g. Scruffy snout, wearing a tattered vest" bind:value={reRollDetails.avatar_look} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.avatar_look = getSuggestion('look')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1.25rem;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What does your Pi-Rat smell like? (Determines name)</label>
|
||||
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" placeholder="e.g. Spiced rum, salty cheese, gunpowder" bind:value={reRollDetails.avatar_smell} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.avatar_smell = getSuggestion('smell')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1.25rem;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What were your first words after transforming?</label>
|
||||
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" placeholder="e.g. Shiver my decimals!" bind:value={reRollDetails.first_words} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.first_words = getSuggestion('first_words')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">Is your Pi-Rat good at Math?</label>
|
||||
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" placeholder="e.g. Thinks Pi is a dessert" bind:value={reRollDetails.good_at_math} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.good_at_math = getSuggestion('good_at_math')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 1rem; margin-top: 1.5rem;">
|
||||
<button class="btn btn-secondary" on:click={() => isRolling = false} style="flex: 1; padding: 0.75rem;">Cancel</button>
|
||||
<button class="btn btn-primary" on:click={submitReRoll} style="flex: 2; padding: 0.75rem; background: var(--gold); border: 1px solid var(--gold); color: black;">Submit New Character</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="between-grid">
|
||||
<!-- Ranks and Voting Card -->
|
||||
<div class="card glass-panel voting-card">
|
||||
<h3>1. Pirate Ranking (Voting)</h3>
|
||||
<p class="section-desc">Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
|
||||
|
||||
{#if state.game.phase === 'deep_upkeep'}
|
||||
<div class="voted-confirmation-box glass-panel">
|
||||
<p class="success-text">✔️ Voting complete! Results tallied.</p>
|
||||
</div>
|
||||
{:else if state.votes.find(v => v.voter_player_id === state.player.id)}
|
||||
<div class="voted-confirmation-box glass-panel">
|
||||
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
||||
</div>
|
||||
{:else if state.player.role === 'deep'}
|
||||
<div class="voted-confirmation-box glass-panel">
|
||||
<p class="info-text">The Deep does not vote.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
|
||||
<div class="form-group inline-group">
|
||||
<select class="select-field" bind:value={selectedVoteId} required>
|
||||
<option value="">Nominate crewmate...</option>
|
||||
{#each state.players as p}
|
||||
{#if p.id !== state.player.id && p.role !== 'deep'}
|
||||
<option value={p.id}>{p.name} (Rank {p.rank})</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Voting Roster Status -->
|
||||
{#if state.game.phase !== 'deep_upkeep'}
|
||||
<div class="vote-status-table margin-top">
|
||||
<h4>Nomination Progress:</h4>
|
||||
<div class="player-chips list-chips" id="voting-roster">
|
||||
{#each state.players as p}
|
||||
{@const voted = state.votes.some(v => v.voter_player_id === p.id) || p.role === 'deep'}
|
||||
<div class="player-chip {voted ? 'voted-chip' : 'not-voted-chip'}">
|
||||
<span class="name">{p.name}</span>
|
||||
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Resting Deep Player Card -->
|
||||
<div class="card glass-panel deep-rest-card">
|
||||
<h3>2. Resting Deep Upkeep</h3>
|
||||
|
||||
{#if state.player.role === "deep"}
|
||||
<div class="deep-rest-panel glass-panel">
|
||||
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p>
|
||||
|
||||
{#if state.game.phase === 'between_scenes'}
|
||||
<p class="info-text text-center gold-text" style="margin-top: 1rem;">Voting is currently in progress. Hand refresh will begin after voting concludes.</p>
|
||||
{:else if state.game.phase === 'deep_upkeep'}
|
||||
<p class="info-text" style="margin-top: 1rem;">
|
||||
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
|
||||
Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
|
||||
</p>
|
||||
|
||||
<div class="upkeep-drag-container">
|
||||
<!-- KEEP HAND ZONE -->
|
||||
<div class="upkeep-box"
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDropToKeep}>
|
||||
<h4 class="gold-text">Hand (Keep)</h4>
|
||||
<div class="upkeep-flex">
|
||||
{#each keepCards as card}
|
||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
|
||||
draggable="true"
|
||||
on:dragstart={(e) => handleDragStart(e, card)}
|
||||
on:click={() => moveToDiscard(card)}
|
||||
style="cursor: pointer;"
|
||||
title="Click or drag to discard">
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
<div class="card-center">
|
||||
{#if card === 'JOKER'}
|
||||
🃏
|
||||
{:else}
|
||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-text text-center w-full">No cards in hand.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DISCARD PILE ZONE -->
|
||||
<div class="upkeep-box"
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDropToDiscard}>
|
||||
<h4 class="gold-text">Discard Pile</h4>
|
||||
<div class="upkeep-flex">
|
||||
{#each discardCards as card}
|
||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
|
||||
draggable="true"
|
||||
on:dragstart={(e) => handleDragStart(e, card)}
|
||||
on:click={() => moveToKeep(card)}
|
||||
style="cursor: pointer;"
|
||||
title="Click or drag to keep">
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
<div class="card-center">
|
||||
{#if card === 'JOKER'}
|
||||
🃏
|
||||
{:else}
|
||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-text text-center w-full">Drag cards here to discard them.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isOverLimit}
|
||||
<div style="border: 1px solid var(--red-suit); border-radius: 4px; padding: 1rem; margin: 1rem 0; background: rgba(255, 42, 95, 0.1);">
|
||||
<p style="color: var(--red-suit); font-weight: bold; margin: 0;">⚠️ Discard Required: You have selected {keepCards.length} cards to keep, which exceeds your maximum hand limit of {maxHandSize}. Please discard at least {keepCards.length - maxHandSize} card(s).</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-primary mt-4 w-full" on:click={confirmRefresh} disabled={confirming || isOverLimit}>
|
||||
Confirm Hand Refresh
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if state.game.phase === 'between_scenes'}
|
||||
<p class="info-text text-center">Your Pi-Rat was active. Your hand cards carry over to the next scene.</p>
|
||||
{:else if state.game.phase === 'deep_upkeep'}
|
||||
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="roster-sheet-summary margin-top text-left glass-panel">
|
||||
<h4>Crew Rank Ledger:</h4>
|
||||
<ul class="ranks-ledger">
|
||||
{#each state.players as p}
|
||||
<li>
|
||||
<strong>{p.name}:</strong> Rank {p.rank}
|
||||
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge" style="background: rgba(120, 150, 180, 0.15); border: 1px solid #7890a8; color: #a0b0c0; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; margin-left: 0.5rem; display: inline-block;">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Scene Ready Up -->
|
||||
<div class="action-box margin-top">
|
||||
{#if state.game.phase === 'deep_upkeep'}
|
||||
{#if state.player.role === 'deep'}
|
||||
<p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
|
||||
{:else}
|
||||
<div class="waiting-box">
|
||||
<p>Waiting for resting Deep player to refresh their hand...</p>
|
||||
<div class="spinner-small"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if state.player.role === 'pirat' && !state.votes.find(v => v.voter_player_id === state.player.id)}
|
||||
<p class="info-text gold-text" style="margin-bottom:0.5rem;">⚠️ You must nominate a crewmate above before you can ready up.</p>
|
||||
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
|
||||
{:else}
|
||||
{#if state.player.is_ready}
|
||||
<div class="waiting-box">
|
||||
<p>Ready! Waiting for other players to ready up...</p>
|
||||
<div class="spinner-small"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<button on:click={setReady}
|
||||
disabled={readying}
|
||||
class="btn btn-primary btn-large glow-effect">
|
||||
Ready for Next Scene
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user