diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f554413..f2d3c65 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,14 +9,17 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/postcss": "^4.1.18", + "chart.js": "^4.5.1", "mongoose": "^9.1.5", "postcss": "^8.5.6", "react": "^19.2.0", + "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.0", "tailwindcss": "^4.1.18" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/chart.js": "^2.9.41", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -979,6 +982,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@mongodb-js/saslprep": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz", @@ -1827,6 +1836,16 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@types/chart.js": { + "version": "2.9.41", + "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz", + "integrity": "sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "moment": "^2.10.2" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2359,6 +2378,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3379,6 +3411,16 @@ "node": "*" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/mongodb": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", @@ -3675,6 +3717,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 229909f..60ad1f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,14 +11,17 @@ }, "dependencies": { "@tailwindcss/postcss": "^4.1.18", + "chart.js": "^4.5.1", "mongoose": "^9.1.5", "postcss": "^8.5.6", "react": "^19.2.0", + "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.0", "tailwindcss": "^4.1.18" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/chart.js": "^2.9.41", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac67a98..4fbeb0c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,9 @@ import GameOverScreen from "../src/components/GameOverScreen"; import bigHouse from "../src/assets/Big_House.png"; import smallHouse from "../src/assets/Small_House.png"; import Dorm from "../src/assets/Dorm.png"; +import { FinalStatsOverlay } from "../src/components/Projection"; +import EventModal from "../src/components/EventModal" + interface InvestmentData { personal?: number; @@ -28,7 +31,8 @@ function App() { 2: bigHouse, }; - const { state, nextRound, resetGame} = useGameState(); + const [showFinalStats, setShowFinalStats] = useState(false); + const [personal, setPersonal] = useState(''); const [career, setCareer] = useState(''); @@ -40,6 +44,14 @@ function App() { const getGif = () => state.sanity >= 50 ? happy : state.sanity >= 25 ? neutral : sad; + const { + state, + nextRound, + pendingEvent, + continueEvent, + resetGame + } = useGameState(); + useEffect(() => { if (!audioRef.current) return; @@ -78,6 +90,25 @@ function App() { return ( <> + + {showFinalStats && ( + { + console.log("Retire button clicked, closing overlay"); + setShowFinalStats(false); + resetGame(); + }} + /> + )} + {state.gameOver ? ( ) : ( @@ -122,6 +153,17 @@ function App() { + = 22 ? "bg-blue-500 hover:bg-blue-600" : "bg-gray-400 cursor-not-allowed" + }`} + disabled={state.age < 22} + onClick={() => state.age >= 22 && setShowFinalStats(true)} + > + Retire! + + + nextRound()}>Advance Round (No Investment) )} diff --git a/frontend/src/components/EventModal.css b/frontend/src/components/EventModal.css new file mode 100644 index 0000000..f39345e --- /dev/null +++ b/frontend/src/components/EventModal.css @@ -0,0 +1,95 @@ +@font-face { + font-family: 'Upheavel'; + src: url('../assets/fonts/upheavtt.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +/* Event Modal Overlay */ +.event-overlay { + position: fixed; + inset: 0; + background: rgba(200, 180, 150, 0.5); /* soft beige semi-transparent */ + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +/* Popup box */ +.event-popup { + position: relative; + width: 420px; + background: #fff4e6; /* cream/beige */ + border: 4px solid #d2b48c; /* soft brown border */ + border-radius: 16px; + box-shadow: 0 8px 20px rgba(100, 60, 20, 0.25); + overflow: hidden; + transform: scale(0.95); + animation: popIn 0.2s ease-out forwards; + font-family: 'Upheavel', sans-serif; +} + +/* Optional top background (like header) */ +.event-bg { + width: 100%; + border-bottom: 2px solid #d2b48c; + border-radius: 16px 16px 0 0; +} + +/* Content area */ +.event-content { + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + color: #5b4636; /* muted brown text */ + text-align: center; +} + +/* Heading */ +.event-content h2 { + font-family: 'Upheavel', sans-serif; + font-size: 1.5rem; + color: #8b5e3c; /* soft brown heading */ + margin: 0; + text-shadow: 1px 1px 2px rgba(255,255,255,0.6); +} + +/* Paragraph text */ +.event-content p { + font-family: 'Upheavel', sans-serif; + font-size: 1.1rem; + margin: 0; + line-height: 1.4; +} + +/* Button */ +.event-content button { + font-family: 'Upheavel', sans-serif; + font-size: 1rem; + background-color: #d2b48c; /* light brown */ + color: #fffaf0; /* cream text */ + border: none; + border-radius: 12px; + padding: 8px 16px; + cursor: pointer; + transition: transform 0.1s ease, background 0.2s ease; +} + +.event-content button:hover { + transform: scale(1.05); + background-color: #a67c52; /* darker brown hover */ +} + +/* Pop-in animation */ +@keyframes popIn { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/frontend/src/components/EventModal.tsx b/frontend/src/components/EventModal.tsx new file mode 100644 index 0000000..8c80752 --- /dev/null +++ b/frontend/src/components/EventModal.tsx @@ -0,0 +1,27 @@ +import type { GameEvent } from "../engine/event"; +import "./EventModal.css"; // make sure CSS is imported + +interface EventModalProps { + event: GameEvent | null; + onContinue: () => void; +} + +export default function EventModal({ event, onContinue }: EventModalProps) { + if (!event) return null; + + return ( + + + + + {event.name} + {event.description} + Continue + + + + ); +} diff --git a/frontend/src/components/HUD/HUD.css b/frontend/src/components/HUD/HUD.css index 3bf89de..5f41f18 100644 --- a/frontend/src/components/HUD/HUD.css +++ b/frontend/src/components/HUD/HUD.css @@ -26,6 +26,7 @@ color: white; font-family: monospace; + font-size: 1.2rem; } .hud-left h3 { diff --git a/frontend/src/components/Projection.css b/frontend/src/components/Projection.css new file mode 100644 index 0000000..168178e --- /dev/null +++ b/frontend/src/components/Projection.css @@ -0,0 +1,104 @@ +@font-face { + font-family: 'Upheavel'; + src: url('../assets/fonts/upheavtt.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +/* Full-screen overlay */ +.overlay { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + background: rgba(200, 180, 150, 0.5); /* soft beige semi-transparent */ + display: flex; + align-items: center; + justify-content: center; + z-index: 99999; + font-family: 'Upheavel', sans-serif; +} + +/* Popup box */ +.overlay-box { + position: relative; + width: 420px; + background: #fff4e6; /* cream/beige */ + border: 4px solid #d2b48c; /* soft brown border */ + border-radius: 16px; + box-shadow: 0 8px 20px rgba(100, 60, 20, 0.25); + padding: 2rem 3rem; + text-align: center; + overflow: hidden; + transform: scale(0.95); + animation: popIn 0.2s ease-out forwards; + color: #5b4636; /* muted brown text */ +} + +/* Optional top header (if you want a separate background) */ +.overlay-box h1 { + font-family: 'Upheavel', sans-serif; + font-size: 1.8rem; + color: #8b5e3c; /* soft brown heading */ + margin-bottom: 1.5rem; + text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.6); +} + +/* Stats grid (vertical for simple layout) */ +.stats-grid { + display: flex; + flex-direction: column; + gap: 1.25rem; + margin-top: 1rem; +} + +/* Individual stat cards */ +.stat-card { + background: #fdf2e9; /* lighter beige card */ + padding: 1rem; + border-radius: 14px; +} + +.stat-card label { + display: block; + font-size: 1.5rem; + opacity: 0.85; + margin-bottom: 0.25rem; +} + +.stat-card span { + font-size: 1.6rem; + font-weight: bold; + color: #6b4f3c; /* deeper brown */ +} + +/* Restart button */ +.btn { + margin-top: 2rem; + padding: 0.9rem 1.6rem; + background-color: #d2b48c; /* light brown */ + color: #fffaf0; /* cream text */ + border: none; + border-radius: 12px; + font-size: 1.5rem; + font-family: 'Upheavel', sans-serif; + cursor: pointer; + transition: transform 0.1s ease, background 0.2s ease; +} + +.btn:hover { + transform: scale(1.05); + background-color: #a67c52; /* darker brown hover */ +} + +/* Pop-in animation */ +@keyframes popIn { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/frontend/src/components/Projection.tsx b/frontend/src/components/Projection.tsx new file mode 100644 index 0000000..d298534 --- /dev/null +++ b/frontend/src/components/Projection.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import type { RoundData } from "../engine/types"; +import "./Projection.css"; + +export const FinalStatsOverlay: React.FC<{ + roundData?: RoundData; + onRestart: () => void; +}> = ({ roundData, onRestart }) => { + const RETIREMENT_AGE = 60; + const AVG_RETURN = 0.04; + + if (!roundData) return ( + + + SYSTEM_SUMMARY: LOADING... + + + ); + + const { age, indexTotal } = roundData; + const yearsInvested = Math.max(age - 18, 1); + const avgContribution = indexTotal / yearsInvested; + + let projectedTotal = indexTotal; + for (let futureAge = age + 1; futureAge <= RETIREMENT_AGE; futureAge++) { + projectedTotal = (projectedTotal + avgContribution) * (1 + AVG_RETURN); + } + + return ( + + + Current age: {age} + + + Amount of money you put into index funds + ${indexTotal.toLocaleString()} + + + At 60, your money would compound to... + ${projectedTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + + Restart! + + + ); +}; diff --git a/frontend/src/engine/event.ts b/frontend/src/engine/event.ts index b119377..02a7a5f 100644 --- a/frontend/src/engine/event.ts +++ b/frontend/src/engine/event.ts @@ -1,6 +1,6 @@ import type { RoundData } from './types'; -interface GameEvent { +export interface GameEvent { id: string; name: string; description: string; @@ -38,9 +38,9 @@ const eventPool: GameEvent[] = [ description: 'You were distracted and rear-ended a luxury sedan. Your car is a mess, and the other driver is furious.', effect: (state) => { let basecost = 4000; - if (state.insurance >= 10) basecost = 1000; - if (state.insurance >= 30) basecost = 500; if (state.insurance >= 40) basecost = 30; + else if (state.insurance >= 30) basecost = 500; + else if (state.insurance >= 10) basecost = 1000; state.total -= basecost; state.sanity -= 10; @@ -116,23 +116,43 @@ const eventPool: GameEvent[] = [ } }, ]; -export function triggerRandomEvent(state: RoundData): RoundData { - // 1. Pick a random index - const randomIndex = Math.floor(Math.random() * eventPool.length); - const selectedEvent = eventPool[randomIndex]; +// export function triggerRandomEvent(state: RoundData): RoundData { +// // 1. Pick a random index +// const randomIndex = Math.floor(Math.random() * eventPool.length); +// const selectedEvent = eventPool[randomIndex]; - // 2. Log it so the player knows what happened - console.group(`EVENT: ${selectedEvent.name}`); - console.log(selectedEvent.description); +// // 2. Log it so the player knows what happened +// console.group(`EVENT: ${selectedEvent.name}`); +// console.log(selectedEvent.description); - // 3. Apply the specific logic of that event - const nextState = selectedEvent.effect(state); +// // 3. Apply the specific logic of that event +// const nextState = selectedEvent.effect(state); - if (state.sanity < 0) { - state.sanity = 0; - } +// if (state.sanity < 0) { +// state.sanity = 0; +// } - console.groupEnd(); +// console.groupEnd(); - return nextState; -} \ No newline at end of file +// return nextState; +// } + +export function getRandomEvent(): GameEvent { + const randomIndex = Math.floor(Math.random() * eventPool.length); + return eventPool[randomIndex]; +} + +export function applyEvent( + state: RoundData, + event: GameEvent +): RoundData { + const next = structuredClone(state); + const result = event.effect(next); + + if (result.sanity < 0) { + result.sanity = 0; + } + + return result; +} + diff --git a/frontend/src/engine/init.ts b/frontend/src/engine/init.ts index 22ffd37..a0f9317 100644 --- a/frontend/src/engine/init.ts +++ b/frontend/src/engine/init.ts @@ -4,11 +4,11 @@ import type { RoundData } from './types'; export function createInitialRound(): RoundData { return { name: "Player", - income: 5000, + income: 1200, total: 0, sanity: 100, insurance: 0, - rent: 700, + rent: 800, careerInvestmentTotal: 0, indexTotal: 0, debtTotal: 0, diff --git a/frontend/src/engine/round.ts b/frontend/src/engine/round.ts index 77ef777..52c5b19 100644 --- a/frontend/src/engine/round.ts +++ b/frontend/src/engine/round.ts @@ -1,51 +1,55 @@ import type { RoundData } from './types'; +import type { GameEvent } from './event'; +import { getRandomEvent } from './event'; + import { applyIncome } from "./income"; import { applyIndexGrowth } from "./indexLogic"; import { applySanityDecay } from "./sanity"; import { applyFixedExpenses } from "./fixed_expenses"; -import { triggerRandomEvent } from './event'; function checkLives(state: RoundData): RoundData { const next = { ...state }; - if (next.sanity <= 0 || next.total < 0) { + if (next.sanity <= 0 || (next.total < 0 && next.level > 1)) { next.lives = Math.max(0, next.lives - 1); if (next.total < 0) next.total = 0; if (next.sanity <= 0) next.sanity = 50; - - console.log(`You lost a life! Lives remaining: ${next.lives}`); } return next; } -export function advanceRound(state: RoundData): RoundData { +export function advanceRound(state: RoundData): { + state: RoundData; + pendingEvent: GameEvent | null; +} { let next = structuredClone(state); next = checkLives(next); if (next.lives === 0) { - console.log("GAME OVER! You have no lives left."); next.gameOver = true; - return next; + return { state: next, pendingEvent: null }; } - console.group(`🌀 ROUND ${next.level}`); - + // ---- Round simulation ---- next = applyIncome(next); next = applyFixedExpenses(next); next = applyIndexGrowth(next); next = applySanityDecay(next); + next.age += 0.25; next.level += 1; - if (next.level % 3 === 0) { - next = triggerRandomEvent(next); - } - - console.log("End of round:", next); - console.groupEnd(); + // ---- Event emission (NOT application) ---- + const pendingEvent = + next.level % 3 === 0 + ? getRandomEvent() + : null; - return next; + return { + state: next, + pendingEvent + }; } diff --git a/frontend/src/state/useGameState.ts b/frontend/src/state/useGameState.ts index 48f1352..ca5dfe0 100644 --- a/frontend/src/state/useGameState.ts +++ b/frontend/src/state/useGameState.ts @@ -1,16 +1,33 @@ import { useState } from "react"; import { createInitialRound } from "../engine/init"; import { advanceRound } from "../engine/round"; -import { investPersonal, investCareer, investIndex, investInsurance } from "../engine/buckets"; -import type { RoundData } from '../engine/types'; +import { applyEvent } from "../engine/event"; // ⬅️ ADD +import { + investPersonal, + investCareer, + investIndex, + investInsurance +} from "../engine/buckets"; + +import type { RoundData } from "../engine/types"; +import type { GameEvent } from "../engine/event"; // ⬅️ ADD + export function useGameState() { const [state, setState] = useState(createInitialRound()); + const [pendingEvent, setPendingEvent] = useState(null); function resetGame() { setState(createInitialRound()); // Reverts state back to the beginning } + function continueEvent() { + if (!pendingEvent) return; + + setState(prev => applyEvent(prev, pendingEvent)); + setPendingEvent(null); +} + function nextRound(investments?: { personal?: number; career?: number; @@ -44,11 +61,19 @@ export function useGameState() { } // Advance the round - nextState = advanceRound(nextState); + const result = advanceRound(nextState); + setPendingEvent(result.pendingEvent); - return nextState; +return result.state; }); } - return { state, nextRound, resetGame}; -} + return { + state, + nextRound, + resetGame, + pendingEvent, + continueEvent + }; +}; +
{event.description}