Refresh the scene dashboard around a crew bubble column
Replace the roster banner and the standalone character-sheet panel with a left-hand column of crew bubbles. Each bubble shows the player's role icon, name, rank, current hand size, and a 🛞 for the Captain; clicking one pops out that player's character sheet as a modal. - New CrewColumn.svelte: bubbles (Pi-Rats first, Deep last) + an Objectives button that opens a crew-objectives popup. - CharacterSheet.svelte is now a target-driven modal: description, likes/hates, and personal objectives for anyone; secret J/Q/K only on your own sheet; the Duel UI (no "Challenge whom?" — target is fixed) only when a living Pi-Rat views another living Pi-Rat. - ScenePhase.svelte → 3-column layout (crew | obstacles | hand/deep); crew column collapses to a horizontal strip on narrow screens. - Shared .modal-backdrop/.modal-box/.modal-close moved into components.css; removed the now-dead roster-banner and captain-badge styles. Backend unchanged — the state blob already carries hands, roles, ranks, objectives, and the captain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
- [ ] **Look for dead code.** While implementing the rollback feature, we discovered /scene/rollback, an unused and buggy single-card-play rollback route that was added but never hooked up to the UI. Look for other code that matches that pattern -- orphan functions and routes that never get called.
|
- [ ] **Look for dead code.** While implementing the rollback feature, we discovered /scene/rollback, an unused and buggy single-card-play rollback route that was added but never hooked up to the UI. Look for other code that matches that pattern -- orphan functions and routes that never get called.
|
||||||
|
|
||||||
- [ ] **Scene dashboard design refresh.** There should be a column of bubbles/buttons to the left of the obstacle list panel. These represent the crew. You can click a Pi-Rat to pop out their character sheet. If the other player is a Pi-Rat in the scene, there's the "challenge them" UI, which no longer needs the "Challenge whom?" since it's tied to a particular character sheet. Finally, there's the personal objective list. There's also an objectives button (at the top level of the hierarchy) which shows a popup for crew objectives. This entirely replaces the character sheet panel on the current dashboard, as well as the roster at the top of the obstacle list. The captain has a steering wheel emoji next to them. The display should also show current hand size for all players.
|
- [x] **Scene dashboard design refresh.** There should be a column of bubbles/buttons to the left of the obstacle list panel. These represent the crew. You can click a Pi-Rat to pop out their character sheet. If the other player is a Pi-Rat in the scene, there's the "challenge them" UI, which no longer needs the "Challenge whom?" since it's tied to a particular character sheet. Finally, there's the personal objective list. There's also an objectives button (at the top level of the hierarchy) which shows a popup for crew objectives. This entirely replaces the character sheet panel on the current dashboard, as well as the roster at the top of the obstacle list. The captain has a steering wheel emoji next to them. The display should also show current hand size for all players.
|
||||||
|
|
||||||
- [ ] Interaction during a challenge should be promoted to the top of the UI
|
- [ ] Interaction during a challenge should be promoted to the top of the UI
|
||||||
|
|
||||||
|
|||||||
@@ -398,25 +398,6 @@
|
|||||||
color: var(--ghost);
|
color: var(--ghost);
|
||||||
}
|
}
|
||||||
|
|
||||||
.captain-badge {
|
|
||||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.captain-badge.vacant {
|
|
||||||
background: color-mix(in srgb, var(--text) 4%, transparent);
|
|
||||||
border: 1px dashed var(--edge);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Waiting indicator --- */
|
/* --- Waiting indicator --- */
|
||||||
.waiting-box, .waiting-indicator {
|
.waiting-box, .waiting-indicator {
|
||||||
@@ -428,3 +409,56 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Modal overlay (shared by popups) --- */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
position: relative;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.75rem;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box.sheet-modal {
|
||||||
|
max-width: 520px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/* --- Scene board layout --- */
|
/* --- Scene board layout --- */
|
||||||
.scene-view-layout {
|
.scene-view-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.4fr 1fr;
|
grid-template-columns: 180px 1.4fr 1fr;
|
||||||
gap: 2rem;
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
@@ -11,6 +12,98 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Crew bubble column --- */
|
||||||
|
.crew-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.1rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-left: 4px solid var(--pirat);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble:hover {
|
||||||
|
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-deep {
|
||||||
|
border-left-color: var(--deep);
|
||||||
|
background: color-mix(in srgb, var(--deep) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-you {
|
||||||
|
border-color: var(--accent);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble.is-ghost { opacity: 0.7; filter: grayscale(0.6); }
|
||||||
|
.crew-bubble.is-dead { opacity: 0.6; filter: grayscale(0.8); }
|
||||||
|
|
||||||
|
.crew-bubble .bubble-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble .bubble-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crew-bubble .bubble-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On narrow screens the crew column becomes a horizontal strip above the board */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.crew-bubbles {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.crew-bubble {
|
||||||
|
width: auto;
|
||||||
|
flex: 1 1 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -32,44 +125,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scene status banner (captain + roster) */
|
|
||||||
.scene-status-banner {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--well);
|
|
||||||
border: 1px solid var(--edge);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster-chips {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster-chips .roster-label {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster-chips .player-chip {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Obstacles --- */
|
/* --- Obstacles --- */
|
||||||
.obstacles-container {
|
.obstacles-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
<script>
|
<script>
|
||||||
import Card from './Card.svelte';
|
import Card from './Card.svelte';
|
||||||
import { displayName } from '../lib/cards';
|
|
||||||
import ChallengePanel from './scene/ChallengePanel.svelte';
|
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||||
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||||
import DeepControlPanel from './scene/DeepControlPanel.svelte';
|
import DeepControlPanel from './scene/DeepControlPanel.svelte';
|
||||||
import CharacterSheet from './scene/CharacterSheet.svelte';
|
import CrewColumn from './scene/CrewColumn.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
|
|
||||||
$: isCaptain = state.game.captain_player_id === state.player.id;
|
|
||||||
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||||
<!-- LEFT COLUMN: The Shared Table -->
|
<!-- LEFT COLUMN: The Crew -->
|
||||||
<div class="table-column">
|
<CrewColumn {state} />
|
||||||
|
|
||||||
<!-- Game Deck and Obstacles Roster -->
|
<!-- MIDDLE COLUMN: The Shared Table -->
|
||||||
|
<div class="table-column">
|
||||||
<div class="card glass-panel obstacle-list-card">
|
<div class="card glass-panel obstacle-list-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>🌊 The Obstacle List</h3>
|
<h3>🌊 The Obstacle List</h3>
|
||||||
@@ -26,45 +24,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="obstacles-container" id="scene-obstacles-container">
|
<div class="obstacles-container" id="scene-obstacles-container">
|
||||||
<!-- TOP STATUS BAR: Scene Info and Captain -->
|
|
||||||
<div class="scene-status-banner">
|
|
||||||
<!-- Captain Badge -->
|
|
||||||
<div>
|
|
||||||
{#if captain}
|
|
||||||
<span class="captain-badge">
|
|
||||||
🏴☠️ Captain: {displayName(captain)} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="captain-badge vacant">
|
|
||||||
🏴☠️ Captain: None{state.game.completed_crew_1 ? '' : ' (steal a ship first!)'}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Active Scene Roster Mini-list -->
|
|
||||||
<div class="roster-chips">
|
|
||||||
<span class="roster-label">Active Roster:</span>
|
|
||||||
{#each state.players as p}
|
|
||||||
{#if p.role === 'deep'}
|
|
||||||
<span class="player-chip deep-chip">
|
|
||||||
🌊 {displayName(p)} (Deep)
|
|
||||||
</span>
|
|
||||||
{:else if p.role === 'pirat'}
|
|
||||||
<span class="player-chip pirat-chip {p.is_ghost ? 'is-ghost' : ''} {p.is_dead ? 'is-dead' : ''} {p.id === state.game.captain_player_id ? 'is-captain' : ''}">
|
|
||||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {displayName(p)} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChallengePanel {state} />
|
<ChallengePanel {state} />
|
||||||
<ObstacleBoard {state} />
|
<ObstacleBoard {state} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
<!-- RIGHT COLUMN: Private Hand / Deep Controls -->
|
||||||
<div class="private-column">
|
<div class="private-column">
|
||||||
{#if state.player.role === "deep"}
|
{#if state.player.role === "deep"}
|
||||||
<DeepControlPanel {state} />
|
<DeepControlPanel {state} />
|
||||||
@@ -90,8 +56,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CharacterSheet {state} />
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,45 +1,62 @@
|
|||||||
<script>
|
<script>
|
||||||
|
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, cardObstacleInfo, obstacleTable } from '../../lib/cards';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
export let target; // the player whose sheet is being viewed
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
function close() { dispatch('close'); }
|
||||||
|
|
||||||
let error = '';
|
let error = '';
|
||||||
let pvpOpponentId = '';
|
|
||||||
let pvpCard = '';
|
let pvpCard = '';
|
||||||
|
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: viewer = state.player;
|
||||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
$: isSelf = target.id === viewer.id;
|
||||||
|
// The viewer throws one of their OWN cards in a duel.
|
||||||
|
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
|
||||||
|
$: canDuel = !isSelf
|
||||||
|
&& viewer.role === 'pirat' && !viewer.is_dead && !viewer.needs_reroll
|
||||||
|
&& target.role === 'pirat' && !target.is_dead;
|
||||||
|
// What the chosen duel card becomes as a temporary Obstacle for the defender
|
||||||
|
$: pvpCardObstacle = cardObstacleInfo(pvpCard, $obstacleTable);
|
||||||
|
|
||||||
function getPlayerName(id) {
|
function getPlayerName(id) {
|
||||||
if (!id) return 'a crewmate';
|
if (!id) return 'a crewmate';
|
||||||
const p = state.players.find(pl => pl.id === id);
|
const p = state.players.find(pl => pl.id === id);
|
||||||
return p ? displayName(p) : 'Unknown';
|
return p ? displayName(p) : 'Unknown';
|
||||||
}
|
}
|
||||||
$: duelTargets = scenePirats.filter(p => p.id !== state.player.id && !p.is_dead);
|
|
||||||
// What the chosen duel card becomes as a temporary Obstacle for the defender
|
|
||||||
$: pvpCardObstacle = cardObstacleInfo(pvpCard, $obstacleTable);
|
|
||||||
|
|
||||||
async function createPvp() {
|
async function createPvp() {
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
|
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/challenge/pvp/create`, 'POST', {
|
||||||
defender_id: pvpOpponentId,
|
defender_id: target.id,
|
||||||
card_code: pvpCard
|
card_code: pvpCard
|
||||||
});
|
});
|
||||||
pvpOpponentId = '';
|
|
||||||
pvpCard = '';
|
pvpCard = '';
|
||||||
|
close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card glass-panel sheet-card">
|
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||||
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
|
||||||
{#if state.player.player_name && state.player.player_name !== state.player.name}
|
<div class="modal-backdrop" on:click|self={close}>
|
||||||
|
<div class="modal-box sheet-modal glass-panel">
|
||||||
|
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
{#if target.role === 'deep'}🌊{:else if target.is_ghost}👻{:else if target.is_dead}💀{:else}🐀{/if}
|
||||||
|
{target.name}'s Character Sheet
|
||||||
|
{#if target.id === state.game.captain_player_id}🛞{/if}
|
||||||
|
</h3>
|
||||||
|
{#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;">
|
||||||
Played by {state.player.player_name}{#if !state.player.completed_personal_2} — they'll go by their smell until they earn a Name!{/if}
|
Played by {target.player_name}{#if !target.completed_personal_2} — they'll go by their smell until they earn a Name!{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -48,28 +65,28 @@
|
|||||||
<div class="alert alert-danger">{error}</div>
|
<div class="alert alert-danger">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if state.player.needs_reroll}
|
{#if isSelf && state.player.needs_reroll}
|
||||||
<div class="prompt-box accent text-center">
|
<div class="prompt-box accent text-center">
|
||||||
<h4>🐀 Recruit Incoming!</h4>
|
<h4>🐀 Recruit Incoming!</h4>
|
||||||
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You don't have a Pi-Rat in this scene. Sit back, enjoy the chaos, and you'll create your recruit with the crew between scenes.</p>
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You don't have a Pi-Rat in this scene. Sit back, enjoy the chaos, and you'll create your recruit with the crew between scenes.</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if state.player.is_dead}
|
{#if isSelf && state.player.is_dead}
|
||||||
<div class="prompt-box danger text-center">
|
<div class="prompt-box danger text-center">
|
||||||
<h4>💀 You have Died / Retired!</h4>
|
<h4>💀 You have Died / Retired!</h4>
|
||||||
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">Your story arc is complete. You will choose to become a Ghost or roll a new character during the upkeep phase between scenes.</p>
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">Your story arc is complete. You will choose to become a Ghost or roll a new character during the upkeep phase between scenes.</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if state.player.is_ghost}
|
{#if isSelf && state.player.is_ghost}
|
||||||
<div class="prompt-box ghostly text-center">
|
<div class="prompt-box ghostly text-center">
|
||||||
<h4>👻 Playing as a Ghost</h4>
|
<h4>👻 Playing as a Ghost</h4>
|
||||||
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You are in the Ghost World (grayscale view). You cannot interact well with the living but you can still help them resolve challenges!</p>
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You are in the Ghost World (grayscale view). You cannot interact well with the living but you can still help them resolve challenges!</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if state.player.tax_banned}
|
{#if isSelf && state.player.tax_banned}
|
||||||
<div class="prompt-box danger text-center">
|
<div class="prompt-box danger text-center">
|
||||||
<p class="info-text" style="margin: 0; font-size: 0.9rem;">🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.</p>
|
<p class="info-text" style="margin: 0; font-size: 0.9rem;">🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,46 +94,43 @@
|
|||||||
|
|
||||||
<div class="sheet-group">
|
<div class="sheet-group">
|
||||||
<h4>Description:</h4>
|
<h4>Description:</h4>
|
||||||
<p><strong>Look:</strong> {state.player.avatar_look}</p>
|
<p><strong>Look:</strong> {target.avatar_look}</p>
|
||||||
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
|
<p><strong>Smell:</strong> {target.avatar_smell}</p>
|
||||||
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
|
<p><strong>First Words:</strong> "{target.first_words}"</p>
|
||||||
{#if state.player.gat_description}
|
{#if target.gat_description}
|
||||||
<p><strong>🔫 Gat:</strong> {state.player.gat_description}</p>
|
<p><strong>🔫 Gat:</strong> {target.gat_description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if state.player.other_like || state.player.other_hate}
|
{#if target.other_like || target.other_hate}
|
||||||
<div class="sheet-group margin-top">
|
<div class="sheet-group margin-top">
|
||||||
<h4>What the Crew Thinks:</h4>
|
<h4>What the Crew Thinks:</h4>
|
||||||
{#if state.player.other_like}
|
{#if target.other_like}
|
||||||
<p><strong>👍 Likes:</strong> "{state.player.other_like}" <span class="text-muted">— {getPlayerName(state.player.other_like_from_player_id)}</span></p>
|
<p><strong>👍 Likes:</strong> "{target.other_like}" <span class="text-muted">— {getPlayerName(target.other_like_from_player_id)}</span></p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if state.player.other_hate}
|
{#if target.other_hate}
|
||||||
<p><strong>👎 Hates:</strong> "{state.player.other_hate}" <span class="text-muted">— {getPlayerName(state.player.other_hate_from_player_id)}</span></p>
|
<p><strong>👎 Hates:</strong> "{target.other_hate}" <span class="text-muted">— {getPlayerName(target.other_hate_from_player_id)}</span></p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Secret techniques are visible only on your own sheet -->
|
||||||
|
{#if isSelf}
|
||||||
<div class="sheet-group margin-top">
|
<div class="sheet-group margin-top">
|
||||||
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
||||||
<ul class="techniques-list-sheet">
|
<ul class="techniques-list-sheet">
|
||||||
<li><strong>Jack (J):</strong> "{state.player.tech_jack}"</li>
|
<li><strong>Jack (J):</strong> "{target.tech_jack}"</li>
|
||||||
<li><strong>Queen (Q):</strong> "{state.player.tech_queen}"</li>
|
<li><strong>Queen (Q):</strong> "{target.tech_queen}"</li>
|
||||||
<li><strong>King (K):</strong> "{state.player.tech_king}"</li>
|
<li><strong>King (K):</strong> "{target.tech_king}"</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Duel another Pi-Rat -->
|
<!-- Duel this Pi-Rat (target is fixed to this sheet) -->
|
||||||
{#if !state.player.is_dead && !state.player.needs_reroll && duelTargets.length > 0}
|
{#if canDuel}
|
||||||
<div class="sheet-group margin-top">
|
<div class="sheet-group margin-top">
|
||||||
<h4>⚔️ Duel a Pi-Rat</h4>
|
<h4>⚔️ Duel {displayName(target)}</h4>
|
||||||
<p class="info-text" style="font-size: 0.85rem;">Challenge a crewmate (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={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
|
|
||||||
<option value="">Challenge whom?</option>
|
|
||||||
{#each duelTargets as p}
|
|
||||||
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<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>
|
||||||
{#each hand.filter(c => !isJoker(c)) as card}
|
{#each hand.filter(c => !isJoker(c)) as card}
|
||||||
@@ -128,44 +142,27 @@
|
|||||||
As an Obstacle, {getCardDisplay(pvpCard)} is <strong>{pvpCardObstacle.title}</strong>: {pvpCardObstacle.description}
|
As an Obstacle, {getCardDisplay(pvpCard)} is <strong>{pvpCardObstacle.title}</strong>: {pvpCardObstacle.description}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
|
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpCard}>Throw Down!</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4>Crew Objectives</h4>
|
|
||||||
<div class="objectives-checklist">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
|
|
||||||
<span>Steal a Ship</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
|
|
||||||
<span>Choose a Captain</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
|
|
||||||
<span>Commit Piracy</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
<div class="sheet-group margin-top">
|
||||||
<h4>Personal Objectives</h4>
|
<h4>Personal Objectives</h4>
|
||||||
<div class="objectives-checklist">
|
<div class="objectives-checklist">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" checked={state.player.completed_personal_1} disabled>
|
<input type="checkbox" checked={target.completed_personal_1} disabled>
|
||||||
<span>1. Gat</span>
|
<span>1. Gat</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" checked={state.player.completed_personal_2} disabled>
|
<input type="checkbox" checked={target.completed_personal_2} disabled>
|
||||||
<span>2. Name</span>
|
<span>2. Name</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" checked={state.player.completed_personal_3} disabled>
|
<input type="checkbox" checked={target.completed_personal_3} disabled>
|
||||||
<span>3. Die/Retire</span>
|
<span>3. Die/Retire</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
81
frontend/src/components/scene/CrewColumn.svelte
Normal file
81
frontend/src/components/scene/CrewColumn.svelte
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script>
|
||||||
|
import CharacterSheet from './CharacterSheet.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let openTargetId = null;
|
||||||
|
let showObjectives = false;
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
|
||||||
|
|
||||||
|
function handSize(p) {
|
||||||
|
try { return JSON.parse(p.hand_cards || '[]').length; } catch { return 0; }
|
||||||
|
}
|
||||||
|
function roleIcon(p) {
|
||||||
|
if (p.role === 'deep') return '🌊';
|
||||||
|
if (p.is_ghost) return '👻';
|
||||||
|
if (p.is_dead) return '💀';
|
||||||
|
return '🐀';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
// CharacterSheet handles its own Escape; this closes the objectives popup.
|
||||||
|
if (e.key === 'Escape') showObjectives = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeydown} />
|
||||||
|
|
||||||
|
<div class="crew-column">
|
||||||
|
<button class="crew-objectives-btn" on:click={() => showObjectives = true}>🎯 Objectives</button>
|
||||||
|
|
||||||
|
<div class="crew-bubbles">
|
||||||
|
{#each crew as p (p.id)}
|
||||||
|
<button
|
||||||
|
class="crew-bubble"
|
||||||
|
class:is-you={p.id === state.player.id}
|
||||||
|
class:is-deep={p.role === 'deep'}
|
||||||
|
class:is-dead={p.is_dead}
|
||||||
|
class:is-ghost={p.is_ghost}
|
||||||
|
title="View {p.name}'s character sheet"
|
||||||
|
on:click={() => openTargetId = p.id}
|
||||||
|
>
|
||||||
|
<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-meta">
|
||||||
|
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if openTarget}
|
||||||
|
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showObjectives}
|
||||||
|
<div class="modal-backdrop" on:click|self={() => showObjectives = false}>
|
||||||
|
<div class="modal-box glass-panel" style="text-align: left;">
|
||||||
|
<button class="modal-close" on:click={() => showObjectives = false} aria-label="Close">×</button>
|
||||||
|
<h3>🎯 Crew Objectives</h3>
|
||||||
|
<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>
|
||||||
|
<div class="objectives-checklist">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
|
||||||
|
<span>Steal a Ship</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
|
||||||
|
<span>Choose a Captain</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
|
||||||
|
<span>Commit Piracy</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
Reference in New Issue
Block a user