diff --git a/src/Game.jsx b/src/Game.jsx new file mode 100644 index 0000000..baa1706 --- /dev/null +++ b/src/Game.jsx @@ -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 ( +
+
+

OTIVM

+

mercator romanus · anno DCCXL ab urbe condita

+
+ +
+ {CHAPTERS.map((id, i) => { + const wp = WAYPOINTS[id] + const ch = i + 1 + const cls = ch < chapter ? 'reached' : ch === chapter ? 'current' : '' + return ( + + {wp.name} + {i < CHAPTERS.length - 1 && ( + + )} + + ) + })} +
+ +
+
+
Denarii
+
{Math.floor(state.den).toLocaleString()}
+
in the strongbox
+
+
+
Auctoritas
+
{Math.floor(state.aut)}
+
reputation
+
+
+
Dispatches
+
{state.dispatches}
+
completed
+
+
+
Location
+
+ {currentLocation.name} +
+
chapter {['I','II','III','IV','V'][chapter - 1]}
+
+
+ + {(dispatch || otium) && ( +
+
+ {progressLabel} + {progressSub} +
+
+
+
+
+ )} + +
{message}
+ +

Trade routes

+
+ {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 ( +
handleSelectRoute(route.id)} + > +
{fromWp.name} → {toWp.name}
+
{route.goods}
+
{route.desc}
+
+ +{route.profit - route.cost} dn + {Math.round(route.duration_ms / 1000)}s +
+ {!unlocked && ( + + {state.den < route.unlock_den + ? `${route.unlock_den.toLocaleString()} dn required` + : `${route.unlock_aut} auctoritas required`} + + )} +
+ ) + })} +
+ +
+ + +
+ +

Merchant's journal

+
+ {journal.length === 0 && ( +
+
Day 1 · Ostia
+
+ 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. +
+
+ )} + {journal.map((entry, i) => { + const key = `${entry.day}-${i}` + const isNew = newEntryKey && i === 0 + return ( +
+
{entry.day}
+
{entry.text}
+
+ ) + })} +
+ +
+ Your save code: {state.token} +
+
+ ) +}