Add lobby and pack concept.
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user