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:
2026-06-14 20:12:44 -07:00
parent 2ea91c066a
commit 045857a8fd
4 changed files with 88 additions and 45 deletions

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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}&nbsp;🛞{/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}

View File

@@ -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;
}