Add Game.jsx — main game component

This commit is contained in:
otivm
2026-04-25 16:21:11 +00:00
parent 00ee85a038
commit 9ef837d8f8

263
src/Game.jsx Normal file
View File

@@ -0,0 +1,263 @@
import { useState, useEffect, useRef } from 'react'
import { ROUTES, WAYPOINTS, OTIUM_DURATION_MS } from './constants.js'
import {
applyDispatch,
applyDispatchCost,
applyOtium,
getNewJournalEntry,
getSeenJournalEntries,
isRouteUnlocked,
getChapter,
} from './gameState.js'
const CHAPTERS = ['ostia', 'capua', 'brundisium', 'carthago', 'alexandria']
export default function Game({ state, onStateChange }) {
const [selectedRoute, setSelectedRoute] = useState(null)
const [dispatch, setDispatch] = useState(null) // { routeId, startMs, durationMs }
const [otium, setOtium] = useState(null) // { startMs }
const [message, setMessage] = useState('')
const [journal, setJournal] = useState(getSeenJournalEntries(state))
const [newEntryKey, setNewEntryKey] = useState(null)
const tickRef = useRef(null)
const msgRef = useRef(null)
function showMessage(text, dur = 3500) {
setMessage(text)
clearTimeout(msgRef.current)
msgRef.current = setTimeout(() => setMessage(''), dur)
}
// Game tick — 250ms
useEffect(() => {
tickRef.current = setInterval(() => {
const now = Date.now()
if (dispatch) {
const elapsed = now - dispatch.startMs
if (elapsed >= dispatch.durationMs) {
// Dispatch complete
setDispatch(null)
const entry = getNewJournalEntry(state, dispatch.routeId)
const newState = applyDispatch(state, dispatch.routeId)
const route = ROUTES.find((r) => r.id === dispatch.routeId)
showMessage(`Galley returned. +${route.profit} denarii.`)
if (entry) {
setJournal((j) => [entry, ...j])
setNewEntryKey(`${dispatch.routeId}-${newState.route_dispatches[dispatch.routeId]}`)
setTimeout(() => setNewEntryKey(null), 5000)
}
setSelectedRoute(null)
onStateChange(newState)
}
}
if (otium) {
const elapsed = now - otium.startMs
if (elapsed >= OTIUM_DURATION_MS) {
setOtium(null)
const newState = applyOtium(state)
const gain = newState.aut - state.aut
showMessage(`Otium complete. +${gain} auctoritas.`)
onStateChange(newState)
}
}
}, 250)
return () => clearInterval(tickRef.current)
}, [dispatch, otium, state, onStateChange])
function handleSelectRoute(routeId) {
if (dispatch || otium) return
if (!isRouteUnlocked(state, routeId)) return
setSelectedRoute((prev) => prev === routeId ? null : routeId)
}
function handleDispatch() {
if (!selectedRoute) { showMessage('Select a trade route first.'); return }
if (dispatch) { showMessage('A galley is already at sea.'); return }
if (otium) { showMessage('Finish your otium first.'); return }
const route = ROUTES.find((r) => r.id === selectedRoute)
if (state.den < route.cost) { showMessage('Not enough denarii for this voyage.'); return }
const newState = applyDispatchCost(state, selectedRoute)
onStateChange(newState)
setDispatch({ routeId: selectedRoute, startMs: Date.now(), durationMs: route.duration_ms })
showMessage(`Galley dispatched on ${route.from}${route.to}.`)
}
function handleOtium() {
if (dispatch) { showMessage('Your galley is at sea. Wait for it to return.'); return }
if (otium) return
setOtium({ startMs: Date.now() })
showMessage('You rest. The harbour sounds fade.')
}
// Progress bar values
let progressPct = 0
let progressLabel = ''
let progressSub = ''
let isOtium = false
if (dispatch) {
const elapsed = Date.now() - dispatch.startMs
progressPct = Math.min((elapsed / dispatch.durationMs) * 100, 100)
const route = ROUTES.find((r) => r.id === dispatch.routeId)
progressLabel = `${WAYPOINTS[route.from].name}${WAYPOINTS[route.to].name}`
progressSub = `${Math.round(progressPct)}%`
} else if (otium) {
const elapsed = Date.now() - otium.startMs
progressPct = Math.min((elapsed / OTIUM_DURATION_MS) * 100, 100)
progressLabel = 'resting...'
progressSub = `${Math.round(progressPct)}%`
isOtium = true
}
const chapter = getChapter(state.den, state.aut)
const currentLocation = WAYPOINTS[CHAPTERS[chapter - 1]]
const busy = !!dispatch || !!otium
return (
<div className="game">
<header className="game-header">
<h1 className="game-title">OTIVM</h1>
<p className="game-subtitle">mercator romanus · anno DCCXL ab urbe condita</p>
</header>
<div className="journey">
{CHAPTERS.map((id, i) => {
const wp = WAYPOINTS[id]
const ch = i + 1
const cls = ch < chapter ? 'reached' : ch === chapter ? 'current' : ''
return (
<span key={id}>
<span className={`journey-node ${cls}`}>{wp.name}</span>
{i < CHAPTERS.length - 1 && (
<span className="journey-arrow"> </span>
)}
</span>
)
})}
</div>
<div className="stats">
<div className="stat">
<div className="stat-label">Denarii</div>
<div className="stat-value">{Math.floor(state.den).toLocaleString()}</div>
<div className="stat-sub">in the strongbox</div>
</div>
<div className="stat">
<div className="stat-label">Auctoritas</div>
<div className="stat-value">{Math.floor(state.aut)}</div>
<div className="stat-sub">reputation</div>
</div>
<div className="stat">
<div className="stat-label">Dispatches</div>
<div className="stat-value">{state.dispatches}</div>
<div className="stat-sub">completed</div>
</div>
<div className="stat">
<div className="stat-label">Location</div>
<div className="stat-value" style={{ fontSize: '0.95rem', paddingTop: '4px' }}>
{currentLocation.name}
</div>
<div className="stat-sub">chapter {['I','II','III','IV','V'][chapter - 1]}</div>
</div>
</div>
{(dispatch || otium) && (
<div className="progress-wrap">
<div className="progress-label">
<span>{progressLabel}</span>
<span>{progressSub}</span>
</div>
<div className="progress-track">
<div
className={`progress-fill${isOtium ? ' otium' : ''}`}
style={{ width: `${progressPct.toFixed(1)}%` }}
/>
</div>
</div>
)}
<div className="message">{message}</div>
<p className="section-title">Trade routes</p>
<div className="route-grid">
{ROUTES.map((route) => {
const unlocked = isRouteUnlocked(state, route.id)
const selected = selectedRoute === route.id
const fromWp = WAYPOINTS[route.from]
const toWp = WAYPOINTS[route.to]
return (
<div
key={route.id}
className={`route-card${!unlocked ? ' locked' : ''}${selected ? ' selected' : ''}`}
onClick={() => handleSelectRoute(route.id)}
>
<div className="route-name">{fromWp.name} {toWp.name}</div>
<div className="route-goods">{route.goods}</div>
<div className="route-desc">{route.desc}</div>
<div className="route-meta">
<span className="route-profit">+{route.profit - route.cost} dn</span>
<span className="route-time">{Math.round(route.duration_ms / 1000)}s</span>
</div>
{!unlocked && (
<span className="route-lock">
{state.den < route.unlock_den
? `${route.unlock_den.toLocaleString()} dn required`
: `${route.unlock_aut} auctoritas required`}
</span>
)}
</div>
)
})}
</div>
<div className="actions">
<button
className="btn btn-dispatch"
onClick={handleDispatch}
disabled={busy || !selectedRoute}
>
Dispatch galley
</button>
<button
className="btn btn-otium"
onClick={handleOtium}
disabled={busy}
>
Take otium
</button>
</div>
<p className="section-title">Merchant's journal</p>
<div className="journal">
{journal.length === 0 && (
<div className="journal-entry">
<div className="journal-date">Day 1 · Ostia</div>
<div className="journal-text">
The harbour smells of pitch and ambition. I have fifty denarii
and a single battered ledger. The factor tells me the olive route
to Capua is open. I shall begin there, as merchants have begun
since the first ships crossed the Tyrrhenian.
</div>
</div>
)}
{journal.map((entry, i) => {
const key = `${entry.day}-${i}`
const isNew = newEntryKey && i === 0
return (
<div key={key} className={`journal-entry${isNew ? ' new' : ''}`}>
<div className="journal-date">{entry.day}</div>
<div className="journal-text">{entry.text}</div>
</div>
)
})}
</div>
<div className="token-bar">
Your save code: <span className="token-code">{state.token}</span>
</div>
</div>
)
}