Switch to Svelte
This commit is contained in:
18
frontend/src/App.svelte
Normal file
18
frontend/src/App.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script>
|
||||
import Router from 'svelte-spa-router';
|
||||
import Home from './pages/Home.svelte';
|
||||
import Join from './pages/Join.svelte';
|
||||
import Dashboard from './pages/Dashboard.svelte';
|
||||
import Admin from './pages/Admin.svelte';
|
||||
|
||||
const routes = {
|
||||
'/': Home,
|
||||
'/game/:id/join': Join,
|
||||
'/game/:id/player/:pid': Dashboard,
|
||||
'/game/:id/admin': Admin,
|
||||
};
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Router {routes} />
|
||||
</main>
|
||||
11
frontend/src/app.css
Normal file
11
frontend/src/app.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@import './assets/css/style.css';
|
||||
@import './assets/css/core.css';
|
||||
@import './assets/css/components.css';
|
||||
@import './assets/css/welcome.css';
|
||||
@import './assets/css/lobby.css';
|
||||
@import './assets/css/character.css';
|
||||
@import './assets/css/scene-setup.css';
|
||||
@import './assets/css/scene-play.css';
|
||||
@import './assets/css/card.css';
|
||||
@import './assets/css/upkeep.css';
|
||||
@import './assets/css/admin.css';
|
||||
28
frontend/src/assets/css/admin.css
Normal file
28
frontend/src/assets/css/admin.css
Normal file
@@ -0,0 +1,28 @@
|
||||
/* --- Admin Panel --- */
|
||||
.admin-players-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.admin-player-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.player-info-basic {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.link-copy-action {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
}
|
||||
244
frontend/src/assets/css/card.css
Normal file
244
frontend/src/assets/css/card.css
Normal file
@@ -0,0 +1,244 @@
|
||||
/* Card Widget (Mini) */
|
||||
.card-mini {
|
||||
width: 32px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--text-muted);
|
||||
background: var(--bg-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 2px 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.card-mini.base-card {
|
||||
border-color: var(--gold);
|
||||
color: var(--gold);
|
||||
background: rgba(212,175,55,0.1);
|
||||
}
|
||||
|
||||
.card-mini.rotated {
|
||||
transform: rotate(90deg);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.card-mini.success {
|
||||
border-color: var(--neon-emerald);
|
||||
color: var(--neon-emerald);
|
||||
}
|
||||
|
||||
.card-mini.failure {
|
||||
border-color: var(--red-suit);
|
||||
color: var(--red-suit);
|
||||
}
|
||||
|
||||
.card-mini .val {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.card-mini .suit {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.card-mini .owner {
|
||||
font-size: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 1px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Card Widget (Medium) */
|
||||
.card-medium {
|
||||
width: 75px;
|
||||
height: 112px;
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid var(--glass-border);
|
||||
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem;
|
||||
position: relative;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.card-medium .card-corner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-medium .card-corner .val {
|
||||
font-weight: 900;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card-medium .card-corner .suit {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.card-medium.suit-c, .card-medium.suit-s {
|
||||
border-color: rgba(226, 232, 240, 0.15);
|
||||
}
|
||||
|
||||
.card-medium.suit-h, .card-medium.suit-d {
|
||||
border-color: rgba(255, 42, 95, 0.15);
|
||||
}
|
||||
|
||||
.card-medium.joker-card {
|
||||
border-color: var(--gold);
|
||||
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
|
||||
}
|
||||
|
||||
.card-medium.suit-c .val, .card-medium.suit-c .suit,
|
||||
.card-medium.suit-s .val, .card-medium.suit-s .suit {
|
||||
color: var(--black-suit);
|
||||
}
|
||||
|
||||
.card-medium.suit-h .val, .card-medium.suit-h .suit,
|
||||
.card-medium.suit-d .val, .card-medium.suit-d .suit {
|
||||
color: var(--red-suit);
|
||||
}
|
||||
|
||||
.card-medium .card-center {
|
||||
font-size: 1.8rem;
|
||||
text-align: center;
|
||||
align-self: center;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.card-medium.suit-c .card-center, .card-medium.suit-s .card-center { color: var(--black-suit); }
|
||||
.card-medium.suit-h .card-center, .card-medium.suit-d .card-center { color: var(--red-suit); }
|
||||
|
||||
/* Card Widget (Large) */
|
||||
.card-large {
|
||||
width: 110px;
|
||||
height: 165px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--glass-border);
|
||||
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem;
|
||||
position: relative;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.card-large:hover {
|
||||
transform: translateY(-8px) scale(1.05);
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 10px 25px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.card-large.suit-c, .card-large.suit-s {
|
||||
border-color: rgba(226, 232, 240, 0.15);
|
||||
}
|
||||
.card-large.suit-c:hover, .card-large.suit-s:hover {
|
||||
border-color: var(--black-suit);
|
||||
box-shadow: 0 10px 25px var(--black-glow);
|
||||
}
|
||||
|
||||
.card-large.suit-h, .card-large.suit-d {
|
||||
border-color: rgba(255, 42, 95, 0.15);
|
||||
}
|
||||
.card-large.suit-h:hover, .card-large.suit-d:hover {
|
||||
border-color: var(--red-suit);
|
||||
box-shadow: 0 10px 25px var(--red-glow);
|
||||
}
|
||||
|
||||
.card-large.joker-card {
|
||||
border-color: var(--gold);
|
||||
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
|
||||
}
|
||||
|
||||
.card-large .card-corner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.card-large .card-corner .val {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.card-large .card-corner .suit {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card-large.suit-c .val, .card-large.suit-c .suit,
|
||||
.card-large.suit-s .val, .card-large.suit-s .suit {
|
||||
color: var(--black-suit);
|
||||
}
|
||||
|
||||
.card-large.suit-h .val, .card-large.suit-h .suit,
|
||||
.card-large.suit-d .val, .card-large.suit-d .suit {
|
||||
color: var(--red-suit);
|
||||
}
|
||||
|
||||
.card-large .card-center {
|
||||
font-size: 2.8rem;
|
||||
text-align: center;
|
||||
align-self: center;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.card-large.suit-c .card-center, .card-large.suit-s .card-center { color: var(--black-suit); }
|
||||
.card-large.suit-h .card-center, .card-large.suit-d .card-center { color: var(--red-suit); }
|
||||
|
||||
.card-large .card-tech-overlay {
|
||||
position: absolute;
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tech-tag {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--gold);
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.joker-action {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Drag and Drop Interface Styles */
|
||||
.card-large[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.card-large[draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.card-large.dragging {
|
||||
opacity: 0.4;
|
||||
border-style: dashed;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.obstacle-item.drag-over {
|
||||
border-color: var(--gold) !important;
|
||||
background: rgba(212, 175, 55, 0.1) !important;
|
||||
box-shadow: 0 0 20px rgba(212, 175, 55, 0.2) !important;
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
442
frontend/src/assets/css/character.css
Normal file
442
frontend/src/assets/css/character.css
Normal file
@@ -0,0 +1,442 @@
|
||||
/* --- Character Creation & Sheet Grid --- */
|
||||
.creation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.creation-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.record-line {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 3px solid var(--gold);
|
||||
}
|
||||
|
||||
.delegation-box {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.delegation-box h4 {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.answer-box {
|
||||
font-style: italic;
|
||||
color: var(--neon-cyan);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.author-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.waiting-box {
|
||||
font-size: 0.9rem;
|
||||
color: var(--gold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inbox-list:has(.inbox-item) .inbox-empty-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inbox-item {
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 3px solid var(--neon-cyan);
|
||||
background: rgba(0, 242, 254, 0.02);
|
||||
}
|
||||
|
||||
.inbox-item label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.submitted-techniques .tech-chip {
|
||||
background: rgba(212,175,55,0.05);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--gold);
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.tech-chip {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tech-assignment-pill {
|
||||
background: rgba(0, 242, 254, 0.05);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
color: var(--text-primary);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-rank {
|
||||
background: var(--neon-cyan);
|
||||
color: var(--bg-dark);
|
||||
font-weight: 900;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* --- Character Sheet layout --- */
|
||||
.character-sheet-details {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sheet-group {
|
||||
background: rgba(0,0,0,0.15);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border-left: 2px solid var(--gold-dark);
|
||||
}
|
||||
|
||||
.sheet-group h4 {
|
||||
color: var(--gold);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.techniques-list-sheet {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.techniques-list-sheet li {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.footnote-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.15rem;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.margin-top-small {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Visual & Tactile Technique Assignment --- */
|
||||
.face-tech-drag-form {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.available-techniques-container {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: inset 0 0 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.available-techniques-container h4 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--gold);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.instruction-help {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.techniques-drag-pool {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.draggable-tech-chip {
|
||||
background: linear-gradient(135deg, rgba(22, 36, 59, 0.9) 0%, rgba(13, 23, 38, 0.9) 100%);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--text-primary);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
transition: var(--transition-smooth);
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3), 0 0 5px rgba(212, 175, 55, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.draggable-tech-chip:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4), 0 0 10px rgba(0, 242, 254, 0.3);
|
||||
}
|
||||
|
||||
.draggable-tech-chip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.draggable-tech-chip.dragging {
|
||||
opacity: 0.4;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.draggable-tech-chip.selected {
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 15px rgba(0, 242, 254, 0.5);
|
||||
background: rgba(0, 242, 254, 0.1);
|
||||
}
|
||||
|
||||
.draggable-tech-chip.assigned-hidden {
|
||||
opacity: 0.2;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
color: var(--gold);
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Slots Grid */
|
||||
.face-cards-slots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.face-cards-slots-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.face-card-slot-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.face-card-slot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
aspect-ratio: 2 / 3;
|
||||
min-height: 280px;
|
||||
background: linear-gradient(135deg, rgba(13, 23, 38, 0.8) 0%, rgba(7, 11, 18, 0.9) 100%);
|
||||
border: 2px dashed rgba(212, 175, 55, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
transition: var(--transition-smooth);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.face-card-slot:hover {
|
||||
border-color: rgba(212, 175, 55, 0.7);
|
||||
box-shadow: 0 12px 30px rgba(212, 175, 55, 0.15);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.face-card-slot.drag-over {
|
||||
border-color: var(--neon-cyan);
|
||||
border-style: solid;
|
||||
background: rgba(0, 242, 254, 0.05);
|
||||
box-shadow: 0 0 20px rgba(0, 242, 254, 0.2);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.face-card-slot.has-assignment {
|
||||
border-style: solid;
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 15px rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.card-bg-letter {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 8rem;
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.025);
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.face-card-slot.has-assignment .card-bg-letter {
|
||||
color: rgba(212, 175, 55, 0.04);
|
||||
}
|
||||
|
||||
.card-slot-header, .card-slot-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.face-card-slot.has-assignment .card-slot-header,
|
||||
.face-card-slot.has-assignment .card-slot-footer {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.card-slot-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.75rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.face-card-slot.has-assignment .card-title {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.drop-zone-placeholder {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
transition: var(--transition-smooth);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.face-card-slot:hover .drop-zone-placeholder {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.assigned-tech-content {
|
||||
width: 100%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.face-card-slot.has-assignment .drop-zone-placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.face-card-slot.has-assignment .assigned-tech-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.assigned-tech-chip {
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--text-primary);
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
word-break: break-word;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.remove-tech-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #c0392b;
|
||||
border: 1px solid #e74c3c;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.5);
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.remove-tech-btn:hover {
|
||||
background: #e74c3c;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
301
frontend/src/assets/css/components.css
Normal file
301
frontend/src/assets/css/components.css
Normal file
@@ -0,0 +1,301 @@
|
||||
/* --- UI Panels & Glassmorphism --- */
|
||||
.glass-panel {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
border-color: rgba(212, 175, 55, 0.35);
|
||||
box-shadow: 0 8px 32px rgba(212, 175, 55, 0.05);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-ocean-light);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--gold);
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Forms & Controls --- */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(7, 11, 18, 0.8);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 10px rgba(0, 242, 254, 0.2);
|
||||
}
|
||||
|
||||
.select-field {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(7, 11, 18, 0.8);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
transition: var(--transition-smooth);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-row .input-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* --- Buttons --- */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-smooth);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-dark) 100%);
|
||||
color: var(--text-dark);
|
||||
box-shadow: 0 4px 15px var(--gold-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(212, 175, 55, 0.6);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-deep {
|
||||
background: linear-gradient(135deg, #152238 0%, #0d1726 100%);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.1);
|
||||
}
|
||||
|
||||
.btn-deep:hover:not(:disabled) {
|
||||
background: var(--neon-cyan);
|
||||
color: var(--text-dark);
|
||||
box-shadow: 0 4px 20px rgba(0, 242, 254, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #7a1c1c 0%, #c0392b 100%);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 15px rgba(192, 57, 43, 0.2);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(192, 57, 43, 0.5);
|
||||
}
|
||||
|
||||
.btn-gold {
|
||||
background: transparent;
|
||||
border: 2px solid var(--gold);
|
||||
color: var(--gold);
|
||||
box-shadow: 0 0 10px rgba(212,175,55,0.1);
|
||||
}
|
||||
|
||||
.btn-gold:hover:not(:disabled) {
|
||||
background: var(--gold);
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 0 20px var(--gold-glow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.glow-effect:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
/* --- Event Log --- */
|
||||
.event-log-fab {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--gold);
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5), 0 0 10px rgba(212, 175, 55, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.event-log-fab:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6), 0 0 15px rgba(212, 175, 55, 0.4);
|
||||
}
|
||||
|
||||
.event-log-panel {
|
||||
position: fixed;
|
||||
bottom: 90px;
|
||||
right: 20px;
|
||||
width: 350px;
|
||||
max-width: calc(100vw - 40px);
|
||||
height: 400px;
|
||||
max-height: calc(100vh - 120px);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 999;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.event-log-panel.hidden {
|
||||
transform: translateY(20px) scale(0.95);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.event-log-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.event-log-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.1rem;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.event-log-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
font-size: 0.9rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--neon-cyan);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.event-msg {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
237
frontend/src/assets/css/core.css
Normal file
237
frontend/src/assets/css/core.css
Normal file
@@ -0,0 +1,237 @@
|
||||
/* --- Variables & Tokens --- */
|
||||
:root {
|
||||
--bg-dark: #070b12;
|
||||
--bg-ocean: #0d1726;
|
||||
--bg-ocean-light: #16243b;
|
||||
|
||||
--gold: #d4af37;
|
||||
--gold-glow: rgba(212, 175, 55, 0.4);
|
||||
--gold-dark: #aa841c;
|
||||
|
||||
--neon-cyan: #00f2fe;
|
||||
--neon-emerald: #00ff87;
|
||||
|
||||
--red-suit: #ff2a5f;
|
||||
--red-glow: rgba(255, 42, 95, 0.3);
|
||||
--black-suit: #e2e8f0;
|
||||
--black-glow: rgba(226, 232, 240, 0.2);
|
||||
|
||||
--glass-bg: rgba(13, 23, 38, 0.7);
|
||||
--glass-bg-hover: rgba(22, 36, 59, 0.85);
|
||||
--glass-border: rgba(212, 175, 55, 0.2);
|
||||
--glass-border-focus: rgba(0, 242, 254, 0.4);
|
||||
|
||||
--text-primary: #f1f5f9;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dark: #0f172a;
|
||||
|
||||
--font-heading: 'Cinzel', serif;
|
||||
--font-body: 'Outfit', sans-serif;
|
||||
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* --- Base Reset & Setup --- */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--gold-dark);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--gold);
|
||||
}
|
||||
|
||||
/* --- Layout Components --- */
|
||||
.app-header {
|
||||
background: rgba(7, 11, 18, 0.95);
|
||||
border-bottom: 2px solid var(--gold);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logo-container h1 {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 2px;
|
||||
background: linear-gradient(135deg, var(--gold) 30%, #fff 70%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 0 10px var(--gold-glow);
|
||||
}
|
||||
|
||||
.math-magic {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 2.2rem;
|
||||
color: var(--neon-cyan);
|
||||
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
|
||||
animation: pulse-glow 3s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.player-pill {
|
||||
background: rgba(0, 242, 254, 0.1);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.player-pill .bullet {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--neon-emerald);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px var(--neon-emerald);
|
||||
}
|
||||
|
||||
.phase-pill {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--gold);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
background: var(--bg-dark);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Loader / Spinner widgets --- */
|
||||
.loader-container {
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid rgba(0, 242, 254, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--neon-cyan);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255,255,255,0.1);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--gold);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* --- Alert styles --- */
|
||||
.alert {
|
||||
padding: 0.75rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: rgba(192, 57, 43, 0.2);
|
||||
border: 1px solid #c0392b;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
/* --- Animation keyframes --- */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0% {
|
||||
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 15px var(--neon-cyan), 0 0 30px var(--neon-cyan), 0 0 40px var(--neon-cyan);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Utilities --- */
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.margin-top { margin-top: 1.5rem; }
|
||||
.gold-text { color: var(--gold); }
|
||||
.center-block { margin: 1rem auto; max-width: 500px; }
|
||||
.inline-form { display: flex; gap: 0.5rem; width: 100%; }
|
||||
.inline-group { display: flex; gap: 0.5rem; width: 100%; }
|
||||
.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; }
|
||||
.select-xsmall { padding: 0.4rem; font-size: 0.85rem; }
|
||||
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
|
||||
|
||||
/* --- Ghost World Styles --- */
|
||||
.ghost-world {
|
||||
filter: grayscale(95%) brightness(0.85) sepia(15%) contrast(1.05);
|
||||
transition: filter 1.2s ease-in-out;
|
||||
}
|
||||
106
frontend/src/assets/css/lobby.css
Normal file
106
frontend/src/assets/css/lobby.css
Normal file
@@ -0,0 +1,106 @@
|
||||
/* --- Lobby / Hold View --- */
|
||||
.lobby-view {
|
||||
max-width: 700px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.lobby-view h2 {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 2.2rem;
|
||||
color: var(--gold);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.lobby-status-box {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.player-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.player-chip {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.player-chip.current-user {
|
||||
background: rgba(0, 242, 254, 0.15);
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 10px rgba(0,242,254,0.15);
|
||||
}
|
||||
|
||||
.player-chip.voted-chip {
|
||||
border-color: var(--neon-emerald);
|
||||
background-color: rgba(0,255,135,0.05);
|
||||
}
|
||||
|
||||
.player-chip.not-voted-chip {
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.creator-badge {
|
||||
background: var(--gold);
|
||||
color: var(--text-dark);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.links-box {
|
||||
text-align: left;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.link-item h4 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.copy-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.copy-input {
|
||||
flex: 1;
|
||||
background: #05080e;
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.copy-input.admin-input {
|
||||
border-color: var(--gold-dark);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.admin-link-item {
|
||||
border-top: 1px dashed rgba(212, 175, 55, 0.3);
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
277
frontend/src/assets/css/scene-play.css
Normal file
277
frontend/src/assets/css/scene-play.css
Normal file
@@ -0,0 +1,277 @@
|
||||
/* --- Scene Play Board Layout --- */
|
||||
.scene-view-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.scene-view-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.deck-counter {
|
||||
font-size: 0.85rem;
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* --- Active Obstacle Item Display --- */
|
||||
.obstacles-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.obstacle-item {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
background: rgba(7, 11, 18, 0.4);
|
||||
display: grid;
|
||||
grid-template-columns: 0.8fr 2fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.obstacle-item {
|
||||
grid-template-columns: 0.8fr 2fr 1.5fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.obstacle-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Color theme mappings for suits */
|
||||
.suit-c { border-left: 4px solid var(--black-suit); }
|
||||
.suit-s { border-left: 4px solid var(--black-suit); }
|
||||
.suit-h { border-left: 4px solid var(--red-suit); }
|
||||
.suit-d { border-left: 4px solid var(--red-suit); }
|
||||
|
||||
.obstacle-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.suit-badge {
|
||||
font-weight: 900;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.suit-c .suit-badge, .suit-s .suit-badge { color: var(--black-suit); }
|
||||
.suit-h .suit-badge, .suit-d .suit-badge { color: var(--red-suit); }
|
||||
|
||||
.card-code {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.obstacle-details h4 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.obstacle-details .desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.obstacle-value-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.obstacle-successes-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 255, 135, 0.04);
|
||||
border: 1px dashed rgba(0, 255, 135, 0.25);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.obstacle-successes-display .val-number {
|
||||
color: var(--neon-emerald);
|
||||
}
|
||||
|
||||
.val-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.val-number {
|
||||
font-size: 1.8rem;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 900;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.played-column {
|
||||
grid-column: span 4;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.played-column {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.played-column h5 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.column-cards-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* --- Challenges Section --- */
|
||||
.challenges-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.challenge-item {
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.challenge-item h4 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--neon-cyan);
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.challenge-item .desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.applied-obstacles h5 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.applied-obs-row {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.obs-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.obs-info .symbol {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.play-card-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select-xsmall {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
width: 140px;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* --- Deep Control panel --- */
|
||||
.deep-text {
|
||||
color: var(--neon-cyan) !important;
|
||||
}
|
||||
|
||||
.deep-divider {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, rgba(0, 242, 254, 0), rgba(0, 242, 254, 0.4), rgba(0, 242, 254, 0));
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.obstacles-checkboxes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hand-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
83
frontend/src/assets/css/scene-setup.css
Normal file
83
frontend/src/assets/css/scene-setup.css
Normal file
@@ -0,0 +1,83 @@
|
||||
/* --- Scene Setup / Role Choose --- */
|
||||
.setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.setup-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.role-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.role-btn {
|
||||
padding: 1.5rem;
|
||||
font-size: 1.2rem;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 2px solid rgba(255,255,255,0.1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-btn.pirat-btn:hover, .role-btn.pirat-btn.active {
|
||||
border-color: var(--neon-emerald);
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(0,255,135,0.06);
|
||||
box-shadow: 0 0 15px rgba(0,255,135,0.15);
|
||||
}
|
||||
|
||||
.role-btn.deep-btn:hover, .role-btn.deep-btn.active {
|
||||
border-color: var(--neon-cyan);
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(0,242,254,0.06);
|
||||
box-shadow: 0 0 15px rgba(0,242,254,0.15);
|
||||
}
|
||||
|
||||
.large-badge {
|
||||
padding: 0.5rem 2rem;
|
||||
font-size: 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-heading);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.role-badge.deep {
|
||||
background: rgba(0, 242, 254, 0.15);
|
||||
border: 2px solid var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
box-shadow: 0 0 15px rgba(0,242,254,0.1);
|
||||
}
|
||||
|
||||
.role-badge.pirat {
|
||||
background: rgba(0, 255, 135, 0.15);
|
||||
border: 2px solid var(--neon-emerald);
|
||||
color: var(--neon-emerald);
|
||||
box-shadow: 0 0 15px rgba(0,255,135,0.1);
|
||||
}
|
||||
|
||||
.list-chips {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.list-chips .player-chip {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.deep-chip {
|
||||
border-color: var(--neon-cyan);
|
||||
background: rgba(0, 242, 254, 0.03);
|
||||
}
|
||||
|
||||
.pirat-chip {
|
||||
border-color: var(--neon-emerald);
|
||||
background: rgba(0, 255, 135, 0.03);
|
||||
}
|
||||
18
frontend/src/assets/css/style.css
Normal file
18
frontend/src/assets/css/style.css
Normal file
@@ -0,0 +1,18 @@
|
||||
/* ==========================================================================
|
||||
RATS WITH GATS DESIGN SYSTEM & STYLESHEET
|
||||
A weathered math-magic pirate aesthetic: deep-ocean dark mode, glassmorphism,
|
||||
neon math runes, gold accents, and animated card widgets.
|
||||
|
||||
This file imports modular stylesheets divided by page phase and components.
|
||||
========================================================================== */
|
||||
|
||||
@import url("core.css");
|
||||
@import url("components.css");
|
||||
@import url("card.css");
|
||||
@import url("welcome.css");
|
||||
@import url("lobby.css");
|
||||
@import url("character.css");
|
||||
@import url("scene-setup.css");
|
||||
@import url("scene-play.css");
|
||||
@import url("upkeep.css");
|
||||
@import url("admin.css");
|
||||
100
frontend/src/assets/css/upkeep.css
Normal file
100
frontend/src/assets/css/upkeep.css
Normal file
@@ -0,0 +1,100 @@
|
||||
/* --- Between Scenes Upkeep --- */
|
||||
.between-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.between-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.voting-card, .deep-rest-card {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.voted-confirmation-box {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--neon-emerald);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--neon-emerald);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ranks-ledger {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ranks-ledger li {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.deep-badge {
|
||||
background: rgba(0, 242, 254, 0.15);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pirat-badge {
|
||||
background: rgba(0, 255, 135, 0.15);
|
||||
border: 1px solid var(--neon-emerald);
|
||||
color: var(--neon-emerald);
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* --- Deep Upkeep Hand Refresh Drag & Drop --- */
|
||||
.upkeep-drag-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.upkeep-drag-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.upkeep-box {
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.upkeep-box h3 {
|
||||
margin-bottom: 1rem;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.upkeep-flex {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: 8px;
|
||||
align-content: flex-start;
|
||||
min-height: 300px;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.upkeep-box[ondragover]:hover .upkeep-flex {
|
||||
border-color: var(--neon-cyan);
|
||||
}
|
||||
50
frontend/src/assets/css/welcome.css
Normal file
50
frontend/src/assets/css/welcome.css
Normal file
@@ -0,0 +1,50 @@
|
||||
/* --- Welcome / Main Menu Screen --- */
|
||||
.welcome-container {
|
||||
max-width: 900px;
|
||||
margin: 4rem auto;
|
||||
}
|
||||
|
||||
.welcome-hero h2 {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 2.5rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.welcome-hero .subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.welcome-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.welcome-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.action-card {
|
||||
padding: 2.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.action-card h3 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--gold);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.action-card p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/svelte.svg
Normal file
1
frontend/src/assets/svelte.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
527
frontend/src/components/CharacterCreationPhase.svelte
Normal file
527
frontend/src/components/CharacterCreationPhase.svelte
Normal file
@@ -0,0 +1,527 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { getSuggestion } from '../lib/suggestions';
|
||||
|
||||
export let state;
|
||||
|
||||
// Form states
|
||||
let basicDetails = {
|
||||
avatar_look: state.player.avatar_look || '',
|
||||
avatar_smell: state.player.avatar_smell || '',
|
||||
first_words: state.player.first_words || '',
|
||||
good_at_math: state.player.good_at_math || ''
|
||||
};
|
||||
|
||||
let savingBasic = false;
|
||||
let delegating = false;
|
||||
|
||||
async function saveBasicDetails() {
|
||||
savingBasic = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails);
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
savingBasic = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function autoDelegate() {
|
||||
delegating = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/delegate/auto`, 'POST');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
delegating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// A helper to get player name
|
||||
function getPlayerName(id) {
|
||||
if (!id) return "None";
|
||||
const p = state.players.find(x => x.id === id);
|
||||
return p ? p.name : "Unknown";
|
||||
}
|
||||
|
||||
// Techniques
|
||||
let tech1 = '', tech2 = '', tech3 = '';
|
||||
let savingTechs = false;
|
||||
|
||||
async function submitTechniques() {
|
||||
savingTechs = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||
tech1, tech2, tech3
|
||||
});
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
savingTechs = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Face Techniques
|
||||
let jackTech = '', queenTech = '', kingTech = '';
|
||||
let assigningFace = false;
|
||||
let faceError = '';
|
||||
|
||||
async function assignFaceTechniques() {
|
||||
assigningFace = true;
|
||||
faceError = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/assign-face-techniques`, 'POST', {
|
||||
jack: jackTech,
|
||||
queen: queenTech,
|
||||
king: kingTech
|
||||
});
|
||||
} catch(e) {
|
||||
faceError = e.message;
|
||||
} finally {
|
||||
assigningFace = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Submit delegated answers
|
||||
let answerLike = '', answerHate = '';
|
||||
async function submitDelegatedAnswer(type, targetId, answer) {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check completion status
|
||||
$: createdTechniques = state.player.created_techniques ? JSON.parse(state.player.created_techniques) : [];
|
||||
$: swappedTechniques = state.player.swapped_techniques ? JSON.parse(state.player.swapped_techniques) : [];
|
||||
|
||||
$: basicComplete = !!(state.player.avatar_look && state.player.avatar_smell && state.player.first_words && state.player.good_at_math);
|
||||
$: delegateComplete = !!(state.player.other_like_from_player_id && state.player.other_hate_from_player_id);
|
||||
$: techniquesComplete = !!(state.player.tech_jack && state.player.tech_queen && state.player.tech_king);
|
||||
|
||||
// Dev Mode
|
||||
let devMode = false;
|
||||
let autoFilling = false;
|
||||
|
||||
async function autoFillAll() {
|
||||
autoFilling = true;
|
||||
try {
|
||||
// 1. Basic details
|
||||
if (!basicComplete) {
|
||||
basicDetails = {
|
||||
avatar_look: 'A scruffy rat',
|
||||
avatar_smell: 'Like old cheese',
|
||||
first_words: 'Give me the loot!',
|
||||
good_at_math: 'No'
|
||||
};
|
||||
await saveBasicDetails();
|
||||
}
|
||||
|
||||
// 2. Delegate
|
||||
if (!delegateComplete && state.players.length >= 2) {
|
||||
await autoDelegate();
|
||||
}
|
||||
|
||||
// 3. Answer questions (might need a quick refresh to get the questions, but we can do our best with current state)
|
||||
// Wait, state needs to be re-fetched to get the assigned questions. We can just fill what's currently in inbox.
|
||||
for (const p of state.players) {
|
||||
if (p.other_like_from_player_id === state.player.id && !p.other_like) {
|
||||
await submitDelegatedAnswer('like', p.id, 'They are sneaky');
|
||||
}
|
||||
if (p.other_hate_from_player_id === state.player.id && !p.other_hate) {
|
||||
await submitDelegatedAnswer('hate', p.id, 'They snore loud');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Techniques
|
||||
if (createdTechniques.length === 0) {
|
||||
tech1 = 'Pocket Sand';
|
||||
tech2 = 'Tail Whip';
|
||||
tech3 = 'Cheese Decoy';
|
||||
await submitTechniques();
|
||||
}
|
||||
|
||||
// 5. Face Techniques - we need the swapped ones. We can't do this synchronously without waiting for the server
|
||||
// to process the state, because swapped_techniques comes from the server once everyone is done.
|
||||
if (!techniquesComplete && swappedTechniques.length === 3) {
|
||||
jackTech = swappedTechniques[0];
|
||||
queenTech = swappedTechniques[1];
|
||||
kingTech = swappedTechniques[2];
|
||||
await assignFaceTechniques();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
autoFilling = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="character-creation-view">
|
||||
<div class="view-header text-center">
|
||||
<h2>Character Sheet Creation</h2>
|
||||
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
|
||||
<div class="flex justify-center items-center gap-2 text-sm text-gray-500 mt-2">
|
||||
<input type="checkbox" id="devModeToggle" bind:checked={devMode} class="accent-gold"/>
|
||||
<label for="devModeToggle">Dev Mode</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if devMode}
|
||||
<div class="bg-dark-900 border border-gold p-4 rounded-lg flex justify-between items-center mb-6 max-w-4xl mx-auto">
|
||||
<span class="text-gold font-bold">🛠 Dev Mode Tools</span>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
on:click={autoFillAll}
|
||||
disabled={autoFilling}>
|
||||
{autoFilling ? 'Auto-Filling...' : 'Auto-Fill Available Fields'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- MAIN PANEL: Grid for Layout -->
|
||||
<div class="creation-grid max-w-5xl mx-auto">
|
||||
|
||||
<!-- SECTION 1: Rat Records (Basic Info) -->
|
||||
<div class="card glass-panel section-basic">
|
||||
<h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3>
|
||||
|
||||
{#if basicComplete}
|
||||
<!-- Saved View -->
|
||||
<div class="read-only-sheet">
|
||||
<div class="record-line"><strong>Look:</strong> {state.player.avatar_look}</div>
|
||||
<div class="record-line"><strong>Smell:</strong> {state.player.avatar_smell}</div>
|
||||
<div class="record-line"><strong>First Words:</strong> "{state.player.first_words}"</div>
|
||||
<div class="record-line"><strong>Good at Math?</strong> {state.player.good_at_math}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Form View -->
|
||||
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
|
||||
<div class="form-group">
|
||||
<label for="avatar_look">What does your Pi-Rat look like?</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="first_words">What were your first words after transforming?</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2: Delegated Questions (Like/Hate) -->
|
||||
<div class="card glass-panel section-delegations">
|
||||
<div class="card-header-flex" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; border-bottom:1px solid rgba(212,175,55,0.2); padding-bottom:0.5rem;">
|
||||
<h3 style="border:none; margin:0; padding:0; color:var(--gold); font-family:var(--font-heading);">II. Crew Delegations {delegateComplete ? '✅' : '❌'}</h3>
|
||||
{#if !delegateComplete}
|
||||
<button on:click={autoDelegate} disabled={delegating || state.players.length < 2} class="btn btn-secondary btn-small">
|
||||
🎲 Auto-Assign Tasks
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="section-desc">You must let other players decide what they like and hate about your character.</p>
|
||||
|
||||
<div class="delegation-list">
|
||||
{#if delegateComplete}
|
||||
<div class="delegation-box glass-panel">
|
||||
<h4>What does another rat LIKE about you?</h4>
|
||||
<div class="answer-box">
|
||||
{state.player.other_like ? `"${state.player.other_like}"` : '(Waiting for answer...)'}
|
||||
</div>
|
||||
<div class="author-label">— {getPlayerName(state.player.other_like_from_player_id)}</div>
|
||||
</div>
|
||||
<div class="delegation-box glass-panel">
|
||||
<h4>What does another rat HATE about you?</h4>
|
||||
<div class="answer-box">
|
||||
{state.player.other_hate ? `"${state.player.other_hate}"` : '(Waiting for answer...)'}
|
||||
</div>
|
||||
<div class="author-label">— {getPlayerName(state.player.other_hate_from_player_id)}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-4 text-sm text-gray-400">Randomly assign crewmates to write your relationships by clicking "Auto-Assign Tasks".</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 3: Answer Delegations for Others -->
|
||||
<div class="card glass-panel section-inbox" style="grid-column: span 2;">
|
||||
<h3>III. Your Inbox (Task List)</h3>
|
||||
<p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p>
|
||||
<div class="inbox-list">
|
||||
{#each state.players as p}
|
||||
{#if p.other_like_from_player_id === state.player.id && !p.other_like}
|
||||
<div class="inbox-item">
|
||||
<label>What do you LIKE about {p.name}?</label>
|
||||
<div class="input-row">
|
||||
<input type="text" class="input-field" bind:value={answerLike} placeholder="e.g. They always share their cheese"/>
|
||||
<button class="btn btn-primary" on:click={() => submitDelegatedAnswer('like', p.id, answerLike)}>Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if p.other_hate_from_player_id === state.player.id && !p.other_hate}
|
||||
<div class="inbox-item">
|
||||
<label>What do you HATE about {p.name}?</label>
|
||||
<div class="input-row">
|
||||
<input type="text" class="input-field" bind:value={answerHate} placeholder="e.g. They snore too loudly"/>
|
||||
<button class="btn btn-primary" on:click={() => submitDelegatedAnswer('hate', p.id, answerHate)}>Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if state.players.filter(p => (p.other_like_from_player_id === state.player.id && !p.other_like) || (p.other_hate_from_player_id === state.player.id && !p.other_hate)).length === 0}
|
||||
<p class="inbox-empty-message text-gray-400 italic">No pending tasks! Wait for other players to assign you tasks.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 4: Techniques -->
|
||||
<div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;">
|
||||
<h3>IV. Secret Techniques {techniquesComplete ? '✅' : '❌'}</h3>
|
||||
|
||||
{#if createdTechniques.length === 0}
|
||||
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
|
||||
<form on:submit|preventDefault={submitTechniques} id="tech-form">
|
||||
<div class="form-group">
|
||||
<label for="tech1">Technique #1:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech1 = getSuggestion('techniques')}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tech2">Technique #2:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech2 = getSuggestion('techniques')}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tech3">Technique #3:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech3 = getSuggestion('techniques')}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
||||
</form>
|
||||
{:else if createdTechniques.length === 3 && swappedTechniques.length < 3}
|
||||
<div class="submitted-techniques text-center mt-4">
|
||||
<div class="tech-chip">#1: {createdTechniques[0]}</div>
|
||||
<div class="tech-chip">#2: {createdTechniques[1]}</div>
|
||||
<div class="tech-chip">#3: {createdTechniques[2]}</div>
|
||||
<p class="waiting-box margin-top flex justify-center mt-4">
|
||||
<span class="spinner-small"></span>
|
||||
Techniques submitted! Waiting for other players to submit so we can shuffle and swap them.
|
||||
</p>
|
||||
</div>
|
||||
{:else if !techniquesComplete}
|
||||
{#if swappedTechniques.length === 3}
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<p class="text-gray-300">Drag and drop your assigned techniques onto your Face Cards:</p>
|
||||
|
||||
{#if faceError}
|
||||
<div class="alert alert-danger">{faceError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="available-techniques-container">
|
||||
<h4>Available Swapped Techniques</h4>
|
||||
<p class="instruction-help">Drag a technique onto a card slot below to assign it.</p>
|
||||
<div class="techniques-drag-pool">
|
||||
{#each swappedTechniques as tech}
|
||||
<div
|
||||
draggable={tech !== jackTech && tech !== queenTech && tech !== kingTech}
|
||||
on:dragstart={(e) => e.dataTransfer.setData('text/plain', tech)}
|
||||
class="draggable-tech-chip {tech === jackTech || tech === queenTech || tech === kingTech ? 'assigned-hidden' : ''}"
|
||||
>
|
||||
<span class="drag-icon">⋮⋮</span> {tech}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual Card Slots Grid -->
|
||||
<div class="face-cards-slots-grid">
|
||||
|
||||
<!-- JACK SLOT -->
|
||||
<div class="face-card-slot-wrapper">
|
||||
<div class="face-card-slot {jackTech ? 'has-assignment' : ''}"
|
||||
on:dragover|preventDefault
|
||||
on:drop={(e) => {
|
||||
const tech = e.dataTransfer.getData('text/plain');
|
||||
if (tech) {
|
||||
if (queenTech === tech) queenTech = '';
|
||||
if (kingTech === tech) kingTech = '';
|
||||
jackTech = tech;
|
||||
}
|
||||
}}>
|
||||
<div class="card-bg-letter">J</div>
|
||||
<div class="card-slot-header">
|
||||
<span class="rank">J</span>
|
||||
</div>
|
||||
<div class="card-slot-body">
|
||||
<div class="card-title">Jack</div>
|
||||
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
||||
<div class="assigned-tech-content">
|
||||
{#if jackTech}
|
||||
<div class="assigned-tech-chip">
|
||||
{jackTech}
|
||||
<span class="remove-tech-btn" on:click={() => jackTech = ''}>×</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-slot-footer">
|
||||
<span></span>
|
||||
<span class="rank">J</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QUEEN SLOT -->
|
||||
<div class="face-card-slot-wrapper">
|
||||
<div class="face-card-slot {queenTech ? 'has-assignment' : ''}"
|
||||
on:dragover|preventDefault
|
||||
on:drop={(e) => {
|
||||
const tech = e.dataTransfer.getData('text/plain');
|
||||
if (tech) {
|
||||
if (jackTech === tech) jackTech = '';
|
||||
if (kingTech === tech) kingTech = '';
|
||||
queenTech = tech;
|
||||
}
|
||||
}}>
|
||||
<div class="card-bg-letter">Q</div>
|
||||
<div class="card-slot-header">
|
||||
<span class="rank">Q</span>
|
||||
</div>
|
||||
<div class="card-slot-body">
|
||||
<div class="card-title">Queen</div>
|
||||
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
||||
<div class="assigned-tech-content">
|
||||
{#if queenTech}
|
||||
<div class="assigned-tech-chip">
|
||||
{queenTech}
|
||||
<span class="remove-tech-btn" on:click={() => queenTech = ''}>×</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-slot-footer">
|
||||
<span></span>
|
||||
<span class="rank">Q</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KING SLOT -->
|
||||
<div class="face-card-slot-wrapper">
|
||||
<div class="face-card-slot {kingTech ? 'has-assignment' : ''}"
|
||||
on:dragover|preventDefault
|
||||
on:drop={(e) => {
|
||||
const tech = e.dataTransfer.getData('text/plain');
|
||||
if (tech) {
|
||||
if (jackTech === tech) jackTech = '';
|
||||
if (queenTech === tech) queenTech = '';
|
||||
kingTech = tech;
|
||||
}
|
||||
}}>
|
||||
<div class="card-bg-letter">K</div>
|
||||
<div class="card-slot-header">
|
||||
<span class="rank">K</span>
|
||||
</div>
|
||||
<div class="card-slot-body">
|
||||
<div class="card-title">King</div>
|
||||
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
||||
<div class="assigned-tech-content">
|
||||
{#if kingTech}
|
||||
<div class="assigned-tech-chip">
|
||||
{kingTech}
|
||||
<span class="remove-tech-btn" on:click={() => kingTech = ''}>×</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-slot-footer">
|
||||
<span></span>
|
||||
<span class="rank">K</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<button
|
||||
class="btn btn-primary btn-large w-full"
|
||||
disabled={assigningFace || !jackTech || !queenTech || !kingTech}
|
||||
on:click={assignFaceTechniques}
|
||||
>
|
||||
{assigningFace ? 'Assigning...' : 'Assign to Cards'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center italic text-gray-400">
|
||||
Waiting for all players to submit techniques so they can be shuffled...
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="assigned-techs text-center mt-4">
|
||||
<div class="tech-assignment-pill"><span class="card-rank">J</span> {state.player.tech_jack}</div>
|
||||
<div class="tech-assignment-pill"><span class="card-rank">Q</span> {state.player.tech_queen}</div>
|
||||
<div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div>
|
||||
<p class="waiting-box margin-top flex justify-center mt-4">
|
||||
<span class="spinner-small"></span>
|
||||
Ready! Waiting for other players to finish J/Q/K assignment...
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- SECTION 5: Crew Creation Progress -->
|
||||
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
|
||||
<h3>V. Crew Creation Status</h3>
|
||||
<p class="section-desc">See the creation progress of the entire crew in real time.</p>
|
||||
<div class="crew-status-list">
|
||||
<ul class="space-y-2 mt-4">
|
||||
{#each state.players as p}
|
||||
<li class="p-3 bg-dark-800 rounded flex justify-between items-center border-l-4 {p.tech_jack ? 'border-green-500' : 'border-gray-500'}">
|
||||
<span>{p.name}</span>
|
||||
<span class="text-sm">
|
||||
{#if p.tech_jack}
|
||||
<span class="text-green-400">Ready</span>
|
||||
{:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3}
|
||||
<span class="text-yellow-400">Assigning Face Cards</span>
|
||||
{:else}
|
||||
<span class="text-gray-400">Writing Sheet</span>
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
79
frontend/src/components/LobbyPhase.svelte
Normal file
79
frontend/src/components/LobbyPhase.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
|
||||
export let state;
|
||||
|
||||
let starting = false;
|
||||
|
||||
async function startGame() {
|
||||
starting = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/lobby/start`, 'POST');
|
||||
// The polling will pick up the phase change automatically
|
||||
} catch (err) {
|
||||
console.error("Failed to start game:", err);
|
||||
starting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="lobby-view text-center">
|
||||
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
|
||||
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
||||
|
||||
<div class="lobby-status-box glass-panel">
|
||||
<h3>Joined Crew members</h3>
|
||||
<div class="player-chips" id="lobby-player-list">
|
||||
{#each state.players as p}
|
||||
<div class="player-chip {p.id === state.player.id ? 'current-user' : ''}">
|
||||
<span class="avatar-icon">🐀</span>
|
||||
<span class="name">{p.name}</span>
|
||||
{#if p.is_creator}<span class="creator-badge">Creator</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links-box glass-panel">
|
||||
<div class="link-item">
|
||||
<h4>Share Join Link:</h4>
|
||||
<div class="copy-container">
|
||||
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/join" class="copy-input" id="join-link-copy">
|
||||
<button on:click={() => { navigator.clipboard.writeText(document.getElementById('join-link-copy').value); alert('Join link copied!'); }} class="btn btn-secondary btn-small">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if state.player.is_creator}
|
||||
<div class="link-item admin-link-item">
|
||||
<h4 class="gold-text">⚠️ Creator Admin Magic Link:</h4>
|
||||
<p class="info-text">Save this admin link! You can use it to recover character sheets if another player loses their connection or link.</p>
|
||||
<div class="copy-container">
|
||||
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/admin" class="copy-input admin-input" id="admin-link-copy">
|
||||
<button on:click={() => { navigator.clipboard.writeText(document.getElementById('admin-link-copy').value); alert('Admin link copied!'); }} class="btn btn-gold btn-small">Copy Admin Link</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<a href="/#/game/{state.game.id}/admin" class="text-sm text-silver underline hover:text-white">Access Admin Panel directly</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="lobby-action">
|
||||
{#if state.player.is_creator}
|
||||
{#if state.players.length >= 1}
|
||||
<button on:click={startGame}
|
||||
disabled={starting}
|
||||
class="btn btn-primary btn-large glow-effect">
|
||||
{starting ? 'Starting...' : 'Start Character Creation'}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-primary btn-large" disabled>Waiting for players...</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="waiting-indicator">
|
||||
<div class="spinner-small"></div>
|
||||
<p>Waiting for Captain to cast the spell...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
503
frontend/src/components/ScenePhase.svelte
Normal file
503
frontend/src/components/ScenePhase.svelte
Normal file
@@ -0,0 +1,503 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
|
||||
export let state;
|
||||
|
||||
let playingCard = false;
|
||||
let error = '';
|
||||
|
||||
// Helpers
|
||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||
$: isCaptain = state.players.reduce((max, p) => p.rank > max.rank ? p : max, state.players[0])?.id === state.player.id;
|
||||
|
||||
async function playCard(obstacleId, cardCode) {
|
||||
playingCard = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
|
||||
obstacle_id: obstacleId,
|
||||
card_code: cardCode
|
||||
});
|
||||
// Removed alert
|
||||
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
playingCard = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function playJoker(obstacleId, cardCode) {
|
||||
playingCard = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-joker`, 'POST', {
|
||||
obstacle_id: obstacleId,
|
||||
card_code: cardCode
|
||||
});
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
playingCard = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function endScene() {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearObstacle(obstacleId) {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleObjective(targetPlayerId, type) {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', {
|
||||
type: type
|
||||
});
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
let showEventLog = false;
|
||||
|
||||
let newNameInput = '';
|
||||
async function submitNewName() {
|
||||
if (!newNameInput.trim()) return;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
|
||||
new_name: newNameInput.trim()
|
||||
});
|
||||
newNameInput = '';
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Parsing card helpers
|
||||
function getCardValue(code) {
|
||||
if (!code) return 0;
|
||||
if (code === 'JOKER') return 0;
|
||||
let rank = code.slice(0, -1);
|
||||
if (['J', 'Q', 'K'].includes(rank)) return 10;
|
||||
if (rank === 'A') return 1;
|
||||
return parseInt(rank);
|
||||
}
|
||||
|
||||
function getCardDisplay(code) {
|
||||
if (!code) return "";
|
||||
if (code === 'JOKER') return "🃏 JOKER";
|
||||
let rank = code.slice(0, -1);
|
||||
let suit = code.slice(-1);
|
||||
let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit;
|
||||
return `${rank}${suitEmoji}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<div class="table-column">
|
||||
|
||||
<!-- Game Deck and Obstacles Roster -->
|
||||
<div class="card glass-panel obstacle-list-card">
|
||||
<div class="card-header">
|
||||
<h3>🌊 The Obstacle List</h3>
|
||||
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
|
||||
</div>
|
||||
|
||||
<div class="obstacles-container" id="scene-obstacles-container">
|
||||
<!-- TOP STATUS BAR: Scene Info and Captain -->
|
||||
<div class="scene-status-banner glass-panel" style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(7, 11, 18, 0.6); border: 1px solid rgba(212, 175, 55, 0.2); border-radius: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||
<!-- Captain Badge -->
|
||||
<div>
|
||||
{#if isCaptain}
|
||||
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); 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: {state.players.find(p => p.id === state.player.id)?.name} (Rank {state.players.find(p => p.id === state.player.id)?.rank})
|
||||
</span>
|
||||
{:else}
|
||||
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
🏴☠️ Captain: {state.players.reduce((max, p) => p.rank > max.rank ? p : max, state.players[0])?.name || 'None'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Active Scene Roster Mini-list -->
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-heading); margin-right: 0.25rem;">Active Roster:</span>
|
||||
{#each state.players as p}
|
||||
{#if p.role === 'deep'}
|
||||
<span class="player-chip deep-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
🌊 {p.name} (Deep)
|
||||
</span>
|
||||
{:else if p.role === 'pirat'}
|
||||
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {p.is_ghost ? 'border-color: #7890a8; color: #a0b0c0; background: rgba(120, 150, 180, 0.1);' : ''} {p.is_dead ? 'border-color: var(--red-suit); color: var(--red-suit); background: rgba(255, 42, 95, 0.1);' : ''} {p.id === state.players.reduce((max, p2) => p2.rank > max.rank ? p2 : max, state.players[0])?.id && !p.is_ghost && !p.is_dead ? 'border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);' : ''}">
|
||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {p.name} (Rank {p.rank}){p.id === state.players.reduce((max, p2) => p2.rank > max.rank ? p2 : max, state.players[0])?.id && !p.is_ghost && !p.is_dead ? ' ⭐' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#each state.obstacles as obs}
|
||||
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
|
||||
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card}
|
||||
{@const success_count = column_cards.filter(c => c.success).length}
|
||||
{@const is_completed = success_count >= state.players.filter(p => p.role !== 'deep').length}
|
||||
<div class="obstacle-item suit-{obs.suit.toLowerCase()} {is_completed ? 'completed' : ''}"
|
||||
data-obstacle-id={obs.id}
|
||||
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
||||
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
||||
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
|
||||
on:drop={(e) => {
|
||||
if (is_completed) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove("drag-over");
|
||||
const cardCode = e.dataTransfer.getData('text/plain');
|
||||
if (cardCode) {
|
||||
if (cardCode === 'JOKER') playJoker(obs.id, cardCode);
|
||||
else playCard(obs.id, cardCode);
|
||||
}
|
||||
}}>
|
||||
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
|
||||
<div class="card-medium suit-{active_card_code.slice(-1).toLowerCase()} {active_card_code === 'JOKER' ? 'joker-card' : ''}" title="Active Card: {getCardDisplay(active_card_code)}">
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)}</span>
|
||||
<span class="suit">{active_card_code === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-center">
|
||||
{#if active_card_code === 'JOKER'}
|
||||
🃏
|
||||
{:else}
|
||||
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)}</span>
|
||||
<span class="suit">{active_card_code === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="obstacle-details">
|
||||
<h4>{obs.title}</h4>
|
||||
<p class="desc">{obs.description}</p>
|
||||
</div>
|
||||
|
||||
<!-- Value Display -->
|
||||
<div class="obstacle-value-display text-center">
|
||||
<span class="val-label">Current Difficulty</span>
|
||||
<span class="val-number">
|
||||
{#if ['J', 'Q', 'K'].includes(obs.original_card.slice(0, -1))}
|
||||
Rank ({state.player.rank})
|
||||
{:else}
|
||||
{obs.current_value}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Successes Tracker -->
|
||||
<div class="obstacle-successes-display text-center">
|
||||
<span class="val-label">Successes</span>
|
||||
<span class="val-number" style="font-size: 1.5rem;">
|
||||
{success_count} / {state.players.filter(p => p.role !== 'deep').length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Played Cards Column -->
|
||||
<div class="played-column">
|
||||
<h5>Card History (Column)</h5>
|
||||
<div class="column-cards-flex">
|
||||
<div class="card-mini base-card">
|
||||
<span class="val">{obs.original_card.slice(0, -1)}</span>
|
||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[obs.original_card.slice(-1)]}</span>
|
||||
</div>
|
||||
{#each column_cards as pc}
|
||||
<div class="card-mini played-card rotated {pc.success ? 'success' : 'failure'}"
|
||||
title="{pc.player_name} played {getCardDisplay(pc.card)}">
|
||||
<span class="val">{pc.card === 'JOKER' ? '🃏' : pc.card.slice(0, -1)}</span>
|
||||
<span class="suit">{pc.card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
|
||||
<span class="owner">{pc.player_name.slice(0,4)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if is_completed && state.player.role === 'deep'}
|
||||
<div class="text-center" style="margin-top: 1rem;">
|
||||
<button class="btn btn-success" on:click={() => clearObstacle(obs.id)} style="width: 100%; border: 1px solid rgba(255,255,255,0.2);">Clear Obstacle</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
||||
{/each}
|
||||
|
||||
<!-- Event Log Removed from inline layout -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
||||
<div class="private-column">
|
||||
<!-- DEEP CONTROLS: End Scene & Objectives -->
|
||||
{#if state.player.role === "deep"}
|
||||
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
|
||||
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
||||
<div class="objectives-checklist">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_1} on:change={() => toggleObjective(state.player.id, 'crew_1')}>
|
||||
<span>Steal a Ship</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_2} on:change={() => toggleObjective(state.player.id, 'crew_2')}>
|
||||
<span>Choose a Captain</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.game.completed_crew_3} on:change={() => toggleObjective(state.player.id, 'crew_3')}>
|
||||
<span>Commit Piracy</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">Pi-Rat Personal Objectives</h4>
|
||||
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
|
||||
{#each state.players.filter(p => p.role === 'pirat') as p}
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
|
||||
<strong>{p.name}</strong>
|
||||
<div class="objectives-checklist" style="margin-top: 0.25rem;">
|
||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_1} on:change={() => toggleObjective(p.id, 'personal_1')}> <span>1. Gat</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_2} on:change={() => toggleObjective(p.id, 'personal_2')}> <span>2. Name</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_3} on:change={() => toggleObjective(p.id, 'personal_3')}> <span>3. Die/Retire</span></label>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- End Scene Button -->
|
||||
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
|
||||
<p class="info-text">Conclude the scene once players have made attempts or the obstacle list is depleted.</p>
|
||||
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
|
||||
End Scene & Proceed to Ranking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Player Hand Card -->
|
||||
<div class="card glass-panel hand-card">
|
||||
<h3>🃏 Your Hand</h3>
|
||||
<p class="section-desc">Keep your cards secret! {#if state.player.role !== "deep"}Drag a card onto an active obstacle to play it.{/if}</p>
|
||||
|
||||
<div class="hand-flex">
|
||||
{#each hand as card}
|
||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
|
||||
draggable={state.player.role !== "deep"}
|
||||
on:dragstart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', card);
|
||||
e.currentTarget.classList.add("dragging");
|
||||
}}
|
||||
on:dragend={(e) => {
|
||||
e.currentTarget.classList.remove("dragging");
|
||||
}}>
|
||||
<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-tech-overlay">
|
||||
{#if card.slice(0, -1) === "J"}
|
||||
<div class="tech-tag" title="Automatic success when played">J: "{state.player.tech_jack}"</div>
|
||||
{:else if card.slice(0, -1) === "Q"}
|
||||
<div class="tech-tag" title="Automatic success when played">Q: "{state.player.tech_queen}"</div>
|
||||
{:else if card.slice(0, -1) === "K"}
|
||||
<div class="tech-tag" title="Automatic success when played">K: "{state.player.tech_king}"</div>
|
||||
{/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">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Character Sheet Card -->
|
||||
{#if state.player.role !== "deep"}
|
||||
<div class="card glass-panel sheet-card">
|
||||
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
||||
|
||||
<div class="character-sheet-details">
|
||||
{#if state.player.is_dead}
|
||||
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.1); border: 1px dashed var(--red-suit); padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
||||
<h4 style="color: var(--red-suit); margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">💀 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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.is_ghost}
|
||||
<div class="sheet-group prompt-box" style="background: rgba(120, 150, 180, 0.1); border: 1px dashed #7890a8; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
||||
<h4 style="color: #a0b0c0; margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">👻 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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.needs_name}
|
||||
<div class="sheet-group prompt-box" style="background: rgba(212, 175, 55, 0.1); border: 1px dashed var(--gold); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
|
||||
<h4 style="color: var(--gold); margin-top: 0;">🏴☠️ You Earned a Name!</h4>
|
||||
<p class="info-text" style="margin-bottom: 0.5rem;">You completed your second objective and ranked up! What is your new Pirate Name?</p>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" bind:value={newNameInput} class="form-control" placeholder="Enter new name...">
|
||||
<button class="btn btn-gold" on:click={submitNewName} disabled={!newNameInput.trim()}>Claim Name</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sheet-group">
|
||||
<h4>Description:</h4>
|
||||
<p><strong>Look:</strong> {state.player.avatar_look}</p>
|
||||
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
|
||||
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
||||
<ul class="techniques-list-sheet">
|
||||
<li><strong>Jack (J):</strong> "{state.player.tech_jack}"</li>
|
||||
<li><strong>Queen (Q):</strong> "{state.player.tech_queen}"</li>
|
||||
<li><strong>King (K):</strong> "{state.player.tech_king}"</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">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">
|
||||
<h4 style="color: var(--gold);">Personal Objectives</h4>
|
||||
<div class="objectives-checklist">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.player.completed_personal_1} disabled>
|
||||
<span>1. Gat</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.player.completed_personal_2} disabled>
|
||||
<span>2. Name</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={state.player.completed_personal_3} disabled>
|
||||
<span>3. Die/Retire</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Event Log -->
|
||||
<div class="floating-event-log {showEventLog ? 'open' : ''}">
|
||||
<button class="toggle-log-btn" on:click={() => showEventLog = !showEventLog}>
|
||||
{showEventLog ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||
</button>
|
||||
|
||||
{#if showEventLog}
|
||||
<div class="log-content font-mono text-sm space-y-2 p-2">
|
||||
{#each state.events as event}
|
||||
<div class="p-2 border-l-2 border-gray-600 bg-black text-gray-400" style="border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 0.25rem;">
|
||||
{event.message}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-2 text-gray-500">No events yet.</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.floating-event-log {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 350px;
|
||||
background: rgba(10, 15, 25, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 60vh;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.toggle-log-btn {
|
||||
background: var(--dark-bg, #1a1a2e);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.toggle-log-btn:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.log-content {
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
.floating-event-log:not(.open) {
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
131
frontend/src/components/SceneSetupPhase.svelte
Normal file
131
frontend/src/components/SceneSetupPhase.svelte
Normal file
@@ -0,0 +1,131 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
|
||||
export let state;
|
||||
|
||||
let settingRole = false;
|
||||
let starting = false;
|
||||
let error = '';
|
||||
let allowedRoles = ['pirat', 'deep'];
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await apiRequest(`/game/${state.game.id}/player/${state.player.id}/allowed-roles`, 'GET');
|
||||
allowedRoles = res.allowed_roles;
|
||||
} catch(e) {
|
||||
console.error("Failed to load allowed roles", e);
|
||||
}
|
||||
});
|
||||
|
||||
async function setRole(role) {
|
||||
settingRole = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-role`, 'POST', { role });
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
settingRole = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startScene() {
|
||||
starting = true;
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/scene/start`, 'POST');
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$: allRolesAssigned = state.players.every(p => p.role !== null);
|
||||
$: deepPlayer = state.players.find(p => p.role === 'deep');
|
||||
$: piratPlayer = state.players.find(p => p.role === 'pirat');
|
||||
$: canStart = allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_creator || state.player.role === 'deep');
|
||||
</script>
|
||||
|
||||
<div class="scene-setup-view text-center">
|
||||
<h2>Scene Setup (Scene #{state.game.current_scene_number})</h2>
|
||||
<p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p>
|
||||
|
||||
<div class="setup-grid">
|
||||
<!-- Role Selection Card -->
|
||||
<div class="card glass-panel role-selection-card">
|
||||
<h3>Select Your Role</h3>
|
||||
|
||||
<div class="role-buttons">
|
||||
<button on:click={() => setRole('pirat')}
|
||||
disabled={settingRole || !allowedRoles.includes('pirat')}
|
||||
class="btn btn-large role-btn pirat-btn {state.player.role === 'pirat' ? 'active' : ''}">
|
||||
🐀 Play Pi-Rat
|
||||
</button>
|
||||
|
||||
<button on:click={() => setRole('deep')}
|
||||
disabled={settingRole || !allowedRoles.includes('deep')}
|
||||
class="btn btn-large role-btn deep-btn {state.player.role === 'deep' ? 'active' : ''}">
|
||||
🌊 Play the Deep
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="role-restrictions mt-4">
|
||||
{#if !allowedRoles.includes('pirat')}
|
||||
{#if state.game.current_scene_number === 1}
|
||||
<p class="text-red-400 text-sm italic">You are the highest ranked player, so you must play The Deep for the first scene!</p>
|
||||
{:else}
|
||||
<p class="text-red-400 text-sm italic">You are the only eligible player for The Deep this scene!</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !allowedRoles.includes('deep')}
|
||||
{#if state.game.current_scene_number === 1}
|
||||
<p class="text-red-400 text-sm italic">Only the highest ranked player(s) can play The Deep in the first scene!</p>
|
||||
{:else}
|
||||
<p class="text-red-400 text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Roster Card -->
|
||||
<div class="card glass-panel roster-card">
|
||||
<h3>Live Roster Selection</h3>
|
||||
<div class="roster-list" id="scene-roster-list">
|
||||
{#each state.players as p}
|
||||
<div class="roster-item">
|
||||
<span class="player-name">{p.name} <span class="text-sm text-gray-400">(Rank {p.rank})</span></span>
|
||||
{#if p.role === 'pirat'}
|
||||
<span class="role-badge pirat">Pi-Rat</span>
|
||||
{:else if p.role === 'deep'}
|
||||
<span class="role-badge deep">The Deep</span>
|
||||
{:else}
|
||||
<span class="role-badge unassigned">Choosing...</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-box margin-top">
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if canStart}
|
||||
<button on:click={startScene}
|
||||
disabled={starting}
|
||||
class="btn btn-primary btn-large glow-effect">
|
||||
{starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'}
|
||||
</button>
|
||||
{:else if !allRolesAssigned}
|
||||
<p class="text-gray-400 italic mt-4">Waiting for all players to choose a role...</p>
|
||||
{:else if !deepPlayer}
|
||||
<p class="text-red-400 italic mt-4">Someone must play as The Deep!</p>
|
||||
{:else if !piratPlayer}
|
||||
<p class="text-red-400 italic mt-4">Someone must play as a Pi-Rat!</p>
|
||||
{:else}
|
||||
<p class="text-gray-400 italic mt-4">Waiting for The Deep or Creator to start the scene...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
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>
|
||||
5
frontend/src/lib/Counter.svelte
Normal file
5
frontend/src/lib/Counter.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
let count = $state(0)
|
||||
</script>
|
||||
|
||||
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>
|
||||
23
frontend/src/lib/api.js
Normal file
23
frontend/src/lib/api.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export async function apiRequest(endpoint, method = 'GET', data = null) {
|
||||
const options = {
|
||||
method,
|
||||
headers: {},
|
||||
};
|
||||
|
||||
if (data) {
|
||||
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
options.body = new URLSearchParams(data).toString();
|
||||
}
|
||||
|
||||
const res = await fetch(`/api${endpoint}`, options);
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const errBody = await res.json();
|
||||
if (errBody.error) msg = errBody.error;
|
||||
else if (errBody.detail) msg = errBody.detail;
|
||||
} catch(e) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
1423
frontend/src/lib/suggestions.js
Normal file
1423
frontend/src/lib/suggestions.js
Normal file
File diff suppressed because it is too large
Load Diff
9
frontend/src/main.js
Normal file
9
frontend/src/main.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { mount } from 'svelte'
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app'),
|
||||
})
|
||||
|
||||
export default app
|
||||
89
frontend/src/pages/Admin.svelte
Normal file
89
frontend/src/pages/Admin.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
|
||||
export let params = {};
|
||||
let gameId = params.id;
|
||||
let adminKey = '';
|
||||
|
||||
let data = null;
|
||||
let error = '';
|
||||
|
||||
onMount(() => {
|
||||
// Try to load admin_key from local storage
|
||||
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
|
||||
if (adminKey) {
|
||||
loadAdminData();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAdminData() {
|
||||
if (!adminKey) return;
|
||||
try {
|
||||
data = await apiRequest(`/game/${gameId}/admin?key=${encodeURIComponent(adminKey)}`);
|
||||
error = '';
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto p-4 space-y-6">
|
||||
<h1 class="text-3xl font-bold text-gold">Admin Panel</h1>
|
||||
|
||||
{#if !data}
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl mb-4">Enter Admin Key</h2>
|
||||
<form on:submit|preventDefault={loadAdminData} class="flex gap-4">
|
||||
<input type="text" bind:value={adminKey} class="form-control flex-grow" placeholder="Admin Key" required/>
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
{#if error}
|
||||
<div class="alert alert-danger mt-4">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-bold text-silver mb-4">Game: {data.game.crew_name}</h2>
|
||||
<p><strong>Phase:</strong> {data.game.phase}</p>
|
||||
<p><strong>Admin Key:</strong> <span class="bg-dark-900 px-2 py-1 font-mono text-sm">{data.game.admin_key}</span></p>
|
||||
|
||||
<h3 class="text-xl font-bold text-silver mt-8 mb-4">Players</h3>
|
||||
<table class="w-full text-left bg-dark-900 rounded border border-gray-700">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700">
|
||||
<th class="p-3">Name</th>
|
||||
<th class="p-3">Role</th>
|
||||
<th class="p-3">Status</th>
|
||||
<th class="p-3">Rank</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.players as p}
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="p-3">{p.name}</td>
|
||||
<td class="p-3">
|
||||
{#if p.role === 'deep'} 🌊 Deep
|
||||
{:else if p.role === 'pirat'} 🐀 Pi-Rat
|
||||
{:else} None
|
||||
{/if}
|
||||
</td>
|
||||
<td class="p-3">
|
||||
{#if p.is_ghost} 👻 Ghost
|
||||
{:else if p.is_dead} 💀 Dead
|
||||
{:else} Alive
|
||||
{/if}
|
||||
</td>
|
||||
<td class="p-3">{p.rank}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mt-8">
|
||||
<a href="/#/game/{gameId}/join" class="btn btn-secondary">Back to Game Join Page</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
77
frontend/src/pages/Dashboard.svelte
Normal file
77
frontend/src/pages/Dashboard.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
|
||||
import LobbyPhase from '../components/LobbyPhase.svelte';
|
||||
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
|
||||
import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
|
||||
import ScenePhase from '../components/ScenePhase.svelte';
|
||||
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
||||
|
||||
export let params = {};
|
||||
let gameId = params.id;
|
||||
let playerId = params.pid;
|
||||
|
||||
let state = null;
|
||||
let error = '';
|
||||
let intervalId;
|
||||
|
||||
async function fetchState() {
|
||||
try {
|
||||
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
|
||||
state = data;
|
||||
error = '';
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchState();
|
||||
// Poll every 2 seconds
|
||||
intervalId = setInterval(fetchState, 2000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger" style="margin: 20px;">
|
||||
Error connecting to game: {error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state}
|
||||
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
||||
{#if state.game.phase === 'lobby'}
|
||||
<LobbyPhase {state} />
|
||||
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques'}
|
||||
<CharacterCreationPhase {state} />
|
||||
{:else if state.game.phase === 'scene_setup'}
|
||||
<SceneSetupPhase {state} />
|
||||
{:else if state.game.phase === 'scene'}
|
||||
<ScenePhase {state} />
|
||||
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
||||
<UpkeepPhase {state} />
|
||||
{:else}
|
||||
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !error}
|
||||
<div class="loading-container">
|
||||
<p>Loading game state...</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
61
frontend/src/pages/Home.svelte
Normal file
61
frontend/src/pages/Home.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script>
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { apiRequest } from '../lib/api';
|
||||
|
||||
let crewName = '';
|
||||
let error = '';
|
||||
let creating = false;
|
||||
|
||||
async function createGame() {
|
||||
if (!crewName.trim()) return;
|
||||
creating = true;
|
||||
error = '';
|
||||
try {
|
||||
const data = await apiRequest('/game', 'POST', { crew_name: crewName });
|
||||
if (data.admin_key) {
|
||||
localStorage.setItem(`admin_key_${data.id}`, data.admin_key);
|
||||
}
|
||||
push(`/game/${data.id}/join?creator=true`);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="welcome-container">
|
||||
<div class="header">
|
||||
<h1>RATS with GATS</h1>
|
||||
<p class="subtitle">A Remote Play Companion App</p>
|
||||
</div>
|
||||
|
||||
<div class="card creation-card">
|
||||
<h2>Start a New Game</h2>
|
||||
<form on:submit|preventDefault={createGame}>
|
||||
<div class="form-group">
|
||||
<label for="crewName">Crew Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="crewName"
|
||||
bind:value={crewName}
|
||||
required
|
||||
placeholder="e.g. The Salty Dogs"
|
||||
class="form-control input-large"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-large btn-block mt-4" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create Game'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="links mt-4">
|
||||
<a href="/rules" class="text-muted">Read the Rules</a>
|
||||
</div>
|
||||
</div>
|
||||
58
frontend/src/pages/Join.svelte
Normal file
58
frontend/src/pages/Join.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { apiRequest } from '../lib/api';
|
||||
export let params = {};
|
||||
let gameId = params.id;
|
||||
let playerName = '';
|
||||
let error = '';
|
||||
let joining = false;
|
||||
|
||||
// We can extract ?creator=true from $querystring if needed,
|
||||
// though the backend determines creator based on if they are the first player.
|
||||
// For UI purposes, we could show "You are the creator" if we want.
|
||||
|
||||
async function joinGame() {
|
||||
if (!playerName.trim()) return;
|
||||
joining = true;
|
||||
error = '';
|
||||
try {
|
||||
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
||||
push(`/game/${gameId}/player/${data.id}`);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
joining = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="welcome-container">
|
||||
<div class="header">
|
||||
<h1>Join Crew</h1>
|
||||
</div>
|
||||
|
||||
<div class="card creation-card">
|
||||
<h2>Enter your Pi-Rat name</h2>
|
||||
<form on:submit|preventDefault={joinGame}>
|
||||
<div class="form-group">
|
||||
<label for="playerName">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="playerName"
|
||||
bind:value={playerName}
|
||||
required
|
||||
class="form-control input-large"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-large btn-block mt-4" disabled={joining}>
|
||||
{joining ? 'Joining...' : 'Join Game'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user