Refine crew column: names, Captain label, slide-out objectives
Follow-up tweaks to the scene dashboard: - Bubbles now show the human player name alongside the Pi-Rat name. - The Captain is named "Captain <name>" (the "Recruit" honorific swapped, or "Captain" prepended once Named) instead of a 🛞 icon — applied on bubbles and the character-sheet/duel headings via new crewLabel/ captainName helpers in lib/cards.js. - "Objectives" → "Crew Objectives" moved to the end of the roster as a left-aligned toggle with a right-aligned X/3 completed counter; it now slides the detailed list open in place (svelte/transition slide) instead of opening a modal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,23 +19,6 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crew-objectives-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.6rem 0.5rem;
|
|
||||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-smooth);
|
|
||||||
}
|
|
||||||
|
|
||||||
.crew-objectives-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.crew-bubbles {
|
.crew-bubbles {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -92,16 +75,63 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Crew Objectives toggle at the end of the roster */
|
||||||
|
.crew-objectives-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-objectives-toggle:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-objectives-toggle .co-count {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-objectives-detail {
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* On narrow screens the crew column becomes a horizontal strip above the board */
|
/* On narrow screens the crew column becomes a horizontal strip above the board */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.crew-bubbles {
|
.crew-bubbles {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
.crew-bubble {
|
.crew-bubble {
|
||||||
width: auto;
|
width: auto;
|
||||||
flex: 1 1 140px;
|
flex: 1 1 140px;
|
||||||
}
|
}
|
||||||
|
.crew-objectives-toggle {
|
||||||
|
width: auto;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
.crew-objectives-detail {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
import { getCardDisplay, isJoker, displayName, cardObstacleInfo, obstacleTable } from '../../lib/cards';
|
import { getCardDisplay, isJoker, displayName, crewLabel, captainName, cardObstacleInfo, obstacleTable } from '../../lib/cards';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
export let target; // the player whose sheet is being viewed
|
export let target; // the player whose sheet is being viewed
|
||||||
@@ -51,8 +51,7 @@
|
|||||||
|
|
||||||
<h3>
|
<h3>
|
||||||
{#if target.role === 'deep'}🌊{:else if target.is_ghost}👻{:else if target.is_dead}💀{:else}🐀{/if}
|
{#if target.role === 'deep'}🌊{:else if target.is_ghost}👻{:else if target.is_dead}💀{:else}🐀{/if}
|
||||||
{target.name}'s Character Sheet
|
{target.id === state.game.captain_player_id ? captainName(target.name) : target.name}'s Character Sheet
|
||||||
{#if target.id === state.game.captain_player_id}🛞{/if}
|
|
||||||
</h3>
|
</h3>
|
||||||
{#if target.player_name && target.player_name !== target.name}
|
{#if target.player_name && target.player_name !== target.name}
|
||||||
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
|
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
|
||||||
@@ -129,7 +128,7 @@
|
|||||||
<!-- Duel this Pi-Rat (target is fixed to this sheet) -->
|
<!-- Duel this Pi-Rat (target is fixed to this sheet) -->
|
||||||
{#if canDuel}
|
{#if canDuel}
|
||||||
<div class="sheet-group margin-top">
|
<div class="sheet-group margin-top">
|
||||||
<h4>⚔️ Duel {displayName(target)}</h4>
|
<h4>⚔️ Duel {crewLabel(target, state.game.captain_player_id)}</h4>
|
||||||
<p class="info-text" style="font-size: 0.85rem;">Challenge them (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
|
<p class="info-text" style="font-size: 0.85rem;">Challenge them (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
|
||||||
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
<option value="">Throw which card?</option>
|
<option value="">Throw which card?</option>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { crewLabel } from '../../lib/cards';
|
||||||
import CharacterSheet from './CharacterSheet.svelte';
|
import CharacterSheet from './CharacterSheet.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
@@ -8,7 +10,9 @@
|
|||||||
|
|
||||||
// Pi-Rats first, the Deep last; otherwise keep join order.
|
// Pi-Rats first, the Deep last; otherwise keep join order.
|
||||||
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
|
||||||
|
$: captainId = state.game.captain_player_id;
|
||||||
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
||||||
|
$: crewDone = [state.game.completed_crew_1, state.game.completed_crew_2, state.game.completed_crew_3].filter(Boolean).length;
|
||||||
|
|
||||||
function handSize(p) {
|
function handSize(p) {
|
||||||
try { return JSON.parse(p.hand_cards || '[]').length; } catch { return 0; }
|
try { return JSON.parse(p.hand_cards || '[]').length; } catch { return 0; }
|
||||||
@@ -19,18 +23,9 @@
|
|||||||
if (p.is_dead) return '💀';
|
if (p.is_dead) return '💀';
|
||||||
return '🐀';
|
return '🐀';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e) {
|
|
||||||
// CharacterSheet handles its own Escape; this closes the objectives popup.
|
|
||||||
if (e.key === 'Escape') showObjectives = false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={onKeydown} />
|
|
||||||
|
|
||||||
<div class="crew-column">
|
<div class="crew-column">
|
||||||
<button class="crew-objectives-btn" on:click={() => showObjectives = true}>🎯 Objectives</button>
|
|
||||||
|
|
||||||
<div class="crew-bubbles">
|
<div class="crew-bubbles">
|
||||||
{#each crew as p (p.id)}
|
{#each crew as p (p.id)}
|
||||||
<button
|
<button
|
||||||
@@ -43,26 +38,24 @@
|
|||||||
on:click={() => openTargetId = p.id}
|
on:click={() => openTargetId = p.id}
|
||||||
>
|
>
|
||||||
<span class="bubble-icon">{roleIcon(p)}</span>
|
<span class="bubble-icon">{roleIcon(p)}</span>
|
||||||
<span class="bubble-name">{p.name}{#if p.id === state.game.captain_player_id} 🛞{/if}</span>
|
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||||
<span class="bubble-meta">
|
<span class="bubble-meta">
|
||||||
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
|
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if openTarget}
|
<!-- Crew Objectives sits at the end of the roster and slides open in place -->
|
||||||
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
<button
|
||||||
{/if}
|
class="crew-objectives-toggle"
|
||||||
|
aria-expanded={showObjectives}
|
||||||
{#if showObjectives}
|
on:click={() => showObjectives = !showObjectives}
|
||||||
<div class="modal-backdrop" on:click|self={() => showObjectives = false}>
|
>
|
||||||
<div class="modal-box glass-panel" style="text-align: left;">
|
<span class="co-label">{showObjectives ? '▾' : '▸'} Crew Objectives</span>
|
||||||
<button class="modal-close" on:click={() => showObjectives = false} aria-label="Close">×</button>
|
<span class="co-count">{crewDone}/3</span>
|
||||||
<h3>🎯 Crew Objectives</h3>
|
</button>
|
||||||
<p class="info-text" style="font-size: 0.85rem;">The whole crew works toward these. The Deep marks them off as you accomplish them.</p>
|
{#if showObjectives}
|
||||||
<div class="objectives-checklist">
|
<div class="crew-objectives-detail" transition:slide>
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
|
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
|
||||||
<span>Steal a Ship</span>
|
<span>Steal a Ship</span>
|
||||||
@@ -76,6 +69,10 @@
|
|||||||
<span>Commit Piracy</span>
|
<span>Commit Piracy</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if openTarget}
|
||||||
|
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -89,3 +89,20 @@ export function displayName(p) {
|
|||||||
export function playerName(players, id) {
|
export function playerName(players, id) {
|
||||||
return displayName(players.find(p => p.id === id));
|
return displayName(players.find(p => p.id === id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Pi-Rat's name with the "Recruit" honorific swapped for "Captain" (or
|
||||||
|
// "Captain" prepended once they've earned a real Name). Used to mark the Captain
|
||||||
|
// in the scene roster without a separate icon.
|
||||||
|
export function captainName(name) {
|
||||||
|
if (!name) return 'Captain';
|
||||||
|
if (name.startsWith('Recruit ')) return 'Captain ' + name.slice('Recruit '.length);
|
||||||
|
return 'Captain ' + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayName, but the Captain is shown as "Captain <name> (player)".
|
||||||
|
export function crewLabel(p, captainId) {
|
||||||
|
if (!p) return '???';
|
||||||
|
const rat = p.id === captainId ? captainName(p.name) : p.name;
|
||||||
|
if (p.player_name && p.player_name !== p.name) return `${rat} (${p.player_name})`;
|
||||||
|
return rat;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user