Add lobby and pack concept.

This commit is contained in:
Greyson Parrelli
2026-07-23 22:01:54 -04:00
parent 9d9acc4577
commit e74f983470
18 changed files with 665 additions and 111 deletions
+2 -2
View File
@@ -15,8 +15,8 @@ async function post(path: string, body: unknown): Promise<Session> {
export type BotDifficulty = 'easy' | 'medium' | 'hard'
export function createGame(name: string, bot?: BotDifficulty): Promise<Session> {
return post('/api/games', bot ? { name, bot } : { name })
export function createGame(name: string): Promise<Session> {
return post('/api/games', { name })
}
export function joinGame(code: string, name: string): Promise<Session> {
+31 -2
View File
@@ -30,6 +30,31 @@ function useFitText(dep: unknown) {
return ref
}
// renderEffect bolds each effect's trigger and renders the colon separating it
// from the action as an arrow, so "Sell: add 1 extra Apple" shows "Sell →" in
// bold. Cards can list several effects joined by " · "; each gets its own
// bolded trigger.
function renderEffect(text: string) {
return text.split(' · ').map((segment, i) => {
const colon = segment.indexOf(':')
const node =
colon === -1 ? (
segment
) : (
<>
<strong>{segment.slice(0, colon)} </strong>
{segment.slice(colon + 1)}
</>
)
return (
<span key={i}>
{i > 0 && ' · '}
{node}
</span>
)
})
}
interface Props {
card: Card
size?: 'sm' | 'md' | 'lg'
@@ -121,7 +146,7 @@ export function CardView({
<>
{card.effectText && (
<div className="card-effect" ref={effectRef}>
{card.effectText}
{renderEffect(card.effectText)}
</div>
)}
<div className="card-bottom">
@@ -131,7 +156,11 @@ export function CardView({
</>
) : (
<div className="card-effect" ref={effectRef}>
{card.effectText ?? (card.food === 'apple' ? '+1 power (this battle)' : '')}
{card.effectText
? renderEffect(card.effectText)
: card.food === 'apple'
? '+1 power (this battle)'
: ''}
</div>
)}
{selected && <div className="card-check"></div>}
+4 -25
View File
@@ -1,14 +1,7 @@
import { useState } from 'react'
import { createGame, joinGame } from '../api'
import type { BotDifficulty } from '../api'
import type { Session } from '../types'
const BOT_LEVELS: { value: BotDifficulty; label: string; blurb: string }[] = [
{ value: 'easy', label: '🐣 Easy', blurb: 'Learns you the ropes' },
{ value: 'medium', label: '🐺 Medium', blurb: 'Puts up a fight' },
{ value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' },
]
// Home is the create/join screen shown when there's no active session.
export function Home({ onSession }: { onSession: (s: Session) => void }) {
const [name, setName] = useState('')
@@ -56,24 +49,10 @@ export function Home({ onSession }: { onSession: (s: Session) => void }) {
>
Host a new game
</button>
<div className="home-divider">
<span>or challenge the computer</span>
</div>
<div className="home-bots">
{BOT_LEVELS.map((b) => (
<button
key={b.value}
className="btn btn-secondary"
disabled={busy}
title={b.blurb}
onClick={() => run(() => createGame(name, b.value))}
>
{b.label}
</button>
))}
</div>
<p className="home-hint muted">
Set up your lobby next pick a pack, add a computer opponent, or
invite a friend.
</p>
<div className="home-divider">
<span>or join a friend</span>
+167 -13
View File
@@ -1,20 +1,174 @@
import type { GameView } from '../types'
import type { ClientMessage, GameView, PlayerView } from '../types'
const BOT_LEVELS: { value: string; label: string; blurb: string }[] = [
{ value: 'easy', label: '🐣 Easy', blurb: 'Learns you the ropes' },
{ value: 'medium', label: '🐺 Medium', blurb: 'Puts up a fight' },
{ value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' },
]
// Lobby is the pre-game setup screen. The host picks a pack, fills the second
// seat with a bot or a friend, can remove players, and starts the game.
// Everyone else sees the same lineup read-only and waits for the host.
export function Lobby({
view,
send,
}: {
view: GameView
send: (m: ClientMessage) => void
}) {
const isHost = view.youSeat === view.hostSeat
const openSeats = view.maxPlayers - view.players.length
const selectedPlayable =
view.packs.find((p) => p.id === view.pack)?.playable ?? false
const canStart = view.players.length >= view.minPlayers && selectedPlayable
export function Lobby({ view }: { view: GameView }) {
return (
<div className="lobby">
<div className="lobby-bounce" aria-hidden>
🐟
<h2 className="lobby-heading">Game lobby</h2>
<div className="lobby-code-row">
<span className="muted">Invite code</span>
<span className="lobby-code">{view.code}</span>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigator.clipboard?.writeText(view.code)}
>
Copy
</button>
</div>
<h2>Waiting for an opponent</h2>
<p className="muted">Share this code so a friend can join:</p>
<div className="lobby-code">{view.code}</div>
<button
className="btn btn-secondary"
onClick={() => navigator.clipboard?.writeText(view.code)}
>
Copy code
</button>
<section className="lobby-section">
<h3 className="lobby-section-title">Card pack</h3>
<div className="pack-grid">
{view.packs.map((pack) => {
const selected = pack.id === view.pack
const disabled = !pack.playable || !isHost
return (
<button
key={pack.id}
className={`pack-card ${selected ? 'is-selected' : ''} ${
pack.playable ? '' : 'is-locked'
}`}
disabled={disabled}
title={pack.playable ? pack.name : `${pack.name} — coming soon`}
onClick={() => send({ type: 'setPack', pack: pack.id })}
>
<span className="pack-emoji" aria-hidden>
{pack.emoji}
</span>
<span className="pack-name">{pack.name}</span>
<span className="pack-tag">
{pack.playable ? (selected ? 'Selected' : 'Available') : 'Coming soon'}
</span>
</button>
)
})}
</div>
{!isHost && (
<p className="muted lobby-note">Only the host can change the pack.</p>
)}
</section>
<section className="lobby-section">
<h3 className="lobby-section-title">
Players ({view.players.length}/{view.maxPlayers})
</h3>
<ul className="seat-list">
{view.players.map((p) => (
<SeatRow
key={p.id}
player={p}
isHostSeat={p.seat === view.hostSeat}
youSeat={view.youSeat}
canRemove={isHost && p.seat !== view.hostSeat}
onRemove={() => send({ type: 'removePlayer', target: p.id })}
/>
))}
{Array.from({ length: openSeats }).map((_, i) => (
<li key={`open-${i}`} className="seat-row seat-open">
{isHost ? (
<div className="seat-open-host">
<span className="muted">
Add a computer opponent, or share the code to invite a friend:
</span>
<div className="seat-bot-picker">
{BOT_LEVELS.map((b) => (
<button
key={b.value}
className="btn btn-secondary btn-sm"
title={b.blurb}
onClick={() => send({ type: 'addBot', difficulty: b.value })}
>
{b.label}
</button>
))}
</div>
</div>
) : (
<span className="muted">Waiting for the host to fill this seat</span>
)}
</li>
))}
</ul>
</section>
{isHost ? (
<button
className="btn btn-primary btn-big"
disabled={!canStart}
onClick={() => send({ type: 'start' })}
>
{view.players.length < view.minPlayers
? 'Waiting for a second player…'
: 'Start game'}
</button>
) : (
<p className="lobby-waiting muted">Waiting for the host to start the game</p>
)}
</div>
)
}
function SeatRow({
player,
isHostSeat,
youSeat,
canRemove,
onRemove,
}: {
player: PlayerView
isHostSeat: boolean
youSeat: number
canRemove: boolean
onRemove: () => void
}) {
return (
<li className="seat-row">
<span className="seat-avatar" aria-hidden>
{player.isBot ? '🤖' : '🧑'}
</span>
<span className="seat-name">
{player.name}
{player.seat === youSeat && <span className="seat-you"> (you)</span>}
</span>
<span className="seat-badges">
{isHostSeat && (
<span className="chip" title="Host">
👑 Host
</span>
)}
{player.isBot ? (
<span className="chip">Computer</span>
) : (
<span className={`conn-dot ${player.connected ? 'on' : 'off'}`} />
)}
</span>
{canRemove && (
<button className="seat-remove" title="Remove from game" onClick={onRemove}>
</button>
)}
</li>
)
}
+18 -2
View File
@@ -73,6 +73,22 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)
}
// A player the host removed from the lobby no longer has a seat.
if (view.youSeat < 0) {
return (
<div className="centered lobby-removed">
<div className="lobby-bounce" aria-hidden>
👋
</div>
<h2>You were removed from the game</h2>
<p className="muted">The host removed you from this lobby.</p>
<button className="btn btn-primary" onClick={onLeave}>
Back to home
</button>
</div>
)
}
const you = view.players[view.youSeat]
const opponents = view.players.filter((p) => p.seat !== view.youSeat)
@@ -82,7 +98,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<div className="topbar-brand" title="Super Auto Pets: The Board Game">
🐾 <span>SAP</span>
</div>
{view.phase !== 'gameover' && (
{view.phase !== 'gameover' && view.phase !== 'lobby' && (
<div className="topbar-round">
Round <strong>{view.round}</strong> / {view.maxRounds}
</div>
@@ -118,7 +134,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<div className="table-body">
<main className="table-main">
{view.phase === 'lobby' && <Lobby view={view} />}
{view.phase === 'lobby' && <Lobby view={view} send={send} />}
{view.phase === 'shop' && <ShopPhase view={view} you={you} send={send} />}
{view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />}
{view.phase === 'battle' && (
+196 -17
View File
@@ -371,15 +371,10 @@ h3 {
gap: 10px;
}
/* The three computer-opponent difficulty buttons share the row evenly. */
.home-bots {
display: flex;
gap: 10px;
}
.home-bots .btn {
flex: 1;
white-space: nowrap;
.home-hint {
text-align: center;
font-size: 0.9rem;
margin: 0;
}
.home-error {
@@ -659,13 +654,17 @@ h3 {
/* ---------- lobby ---------- */
.lobby {
min-height: 60vh;
max-width: 620px;
margin: 0 auto;
padding: 8px 16px 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
gap: 22px;
}
.lobby-heading {
text-align: center;
color: var(--gold);
}
.lobby-bounce {
@@ -674,19 +673,199 @@ h3 {
animation: float 2s ease-in-out infinite;
}
.lobby-removed {
gap: 14px;
text-align: center;
}
.lobby-code-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.lobby-code {
font-family: var(--font-display);
font-size: 3rem;
letter-spacing: 0.35em;
padding: 14px 30px 14px calc(30px + 0.35em);
font-size: 1.8rem;
letter-spacing: 0.3em;
padding: 6px 16px 6px calc(16px + 0.3em);
color: var(--gold);
background: rgba(0, 0, 0, 0.25);
border: 3px dashed rgba(246, 201, 78, 0.5);
border-radius: 16px;
border-radius: 14px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
box-shadow: var(--tray-inset);
}
.lobby-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.lobby-section-title {
font-family: var(--font-display);
font-size: 1.15rem;
color: var(--cream);
}
.lobby-note,
.lobby-waiting {
text-align: center;
}
.lobby-waiting {
font-size: 1.05rem;
}
/* ---------- pack picker ---------- */
.pack-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.pack-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 16px 10px 12px;
border-radius: var(--card-radius);
border: 3px solid transparent;
background: rgba(0, 0, 0, 0.22);
color: var(--cream);
cursor: pointer;
transition: transform 0.12s ease, border-color 0.12s ease;
}
.pack-card:not(:disabled):hover {
transform: translateY(-2px);
}
.pack-card.is-selected {
border-color: var(--gold);
background: rgba(246, 201, 78, 0.14);
}
.pack-card.is-locked {
opacity: 0.45;
cursor: not-allowed;
filter: grayscale(0.7);
}
.pack-card:disabled {
cursor: not-allowed;
}
.pack-emoji {
font-size: 2.4rem;
line-height: 1;
}
.pack-name {
font-family: var(--font-display);
font-size: 0.95rem;
}
.pack-tag {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gold);
}
.pack-card.is-locked .pack-tag {
color: var(--cream);
}
/* ---------- seat list ---------- */
.seat-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.seat-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
box-shadow: var(--tray-inset);
}
.seat-open {
border: 2px dashed rgba(251, 241, 215, 0.25);
background: rgba(0, 0, 0, 0.12);
box-shadow: none;
}
.seat-avatar {
font-size: 1.5rem;
line-height: 1;
}
.seat-name {
font-weight: 800;
flex: 1;
}
.seat-you {
color: var(--gold);
font-weight: 700;
}
.seat-badges {
display: flex;
align-items: center;
gap: 8px;
}
.seat-remove {
border: none;
background: rgba(217, 74, 56, 0.2);
color: var(--red-soft);
font-weight: 900;
width: 26px;
height: 26px;
border-radius: 50%;
cursor: pointer;
line-height: 1;
}
.seat-remove:hover {
background: var(--red);
color: #fff;
}
.seat-open-host {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.seat-bot-picker {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.seat-bot-picker .btn {
flex: 1;
white-space: nowrap;
}
/* ---------- cards ---------- */
/* Thick printed stock: a warm parchment face, a soft ink border with an inner
keyline, a physical shadow, and a lit sheen along the top. */
+16
View File
@@ -92,6 +92,13 @@ export interface LogEntry {
spawn?: string // 'apple' | 'bee'
}
export interface PackInfo {
id: string
name: string
emoji: string
playable: boolean
}
export interface GameView {
gameId: string
code: string
@@ -99,6 +106,11 @@ export interface GameView {
round: number
maxRounds: number
maxPets: number
pack: string
packs: PackInfo[]
hostSeat: number
minPlayers: number
maxPlayers: number
youSeat: number
turn: number
shopRow: Card[]
@@ -112,6 +124,10 @@ export interface GameView {
}
export type ClientMessage =
| { type: 'setPack'; pack: string }
| { type: 'addBot'; difficulty: string }
| { type: 'removePlayer'; target: string }
| { type: 'start' }
| { type: 'buy'; row: number }
| { type: 'sell'; cards: string[] }
| { type: 'trade'; cards: string[] }