diff --git a/src/Constants.tsx b/src/Constants.tsx index 1122eb6..7ba637b 100644 --- a/src/Constants.tsx +++ b/src/Constants.tsx @@ -68,11 +68,12 @@ export const LOCATIONS = { export const OUTSKIRTS_WIND_MULTIPLIER = 2; // https://github.com/toddmedema/electrify/issues/96 export const EQUATOR_RADIANCE = 1000; // at sea level, equator, clear day, noon https://en.wikipedia.org/wiki/Solar_irradiance +// How long between each simulated frame export const TICK_MS = { - PAUSED: 250, - SLOW: 100, - NORMAL: 40, - FAST: 1, + PAUSED: 250, // pause doesn't actually simulate frames, this is just the timeout timer + SLOW: 200, + NORMAL: 60, + FAST: 15, }; export const INFLATION = 0.03; diff --git a/src/reducers/Game.tsx b/src/reducers/Game.tsx index 1de7b04..d2318fa 100644 --- a/src/reducers/Game.tsx +++ b/src/reducers/Game.tsx @@ -81,6 +81,7 @@ interface NewGameAction { location: LocationType; } +let previousTickMs = 0; let previousSpeed = "PAUSED" as SpeedType; let previousMonth = ""; const initialGame: GameType = { @@ -109,6 +110,7 @@ export const gameSlice = createSlice({ } if (state.speed === "PAUSED") { + previousTickMs = performance.now(); setTimeout( () => store.dispatch(gameSlice.actions.tick()), TICK_MS.PAUSED @@ -116,210 +118,18 @@ export const gameSlice = createSlice({ return; } - state.date = getDateFromMinute( - state.date.minute + TICK_MINUTES, - state.startingYear - ); - const now = getTimeFromTimeline(state.date.minute, state.timeline); - const prev = getTimeFromTimeline( - state.date.minute - TICK_MINUTES, - state.timeline - ); - if (now && prev) { - updateSupplyFacilitiesFinances(state, prev, now); - - if (previousMonth !== state.date.month) { - previousMonth = state.date.month; - const history = state.monthlyHistory; - const { cash, customers } = now; - - // Record final history for the month, then generate the new timeline - history.unshift( - summarizeTimeline(state.timeline, state.startingYear) - ); - state.timeline = generateNewTimeline(state, cash, customers); - - // Pre-roll a few frames to compensate for temperature / demand jumps across months - for (let i = 0; i < 4; i++) { - updateSupplyFacilitiesFinances( - state, - state.timeline[0], - state.timeline[0], - true - ); - } - - // ===== TRIGGERS ====== - // Failure: Bankrupt - if (now.cash < 0) { - logEvent("scenario_end", { - id: state.scenarioId, - type: "bankrupt", - difficulty: state.difficulty, - }); - const summary = summarizeHistory(history); - setTimeout( - () => - store.dispatch( - dialogOpen({ - title: "Bankrupt!", - message: `You've run out of money. - You survived for ${store.getState().game.date.year - store.getState().game.startingYear} years, - earned ${formatMoneyConcise(summary.revenue)} in revenue - and emitted ${numbro(summary.kgco2e / 1000).format({ thousandSeparated: true, mantissa: 0 })} tons of pollution.`, - open: true, - notCancellable: true, - actionLabel: "Try again", - action: () => store.dispatch(gameSlice.actions.quit()), - }) - ), - 1 - ); - } - - // Failure: Too many blackouts - if ( - history[1] && - history[2] && - history[3] && - history[1].supplyWh < history[1].demandWh * 0.9 && - history[2].supplyWh < history[2].demandWh * 0.9 && - history[3].supplyWh < history[3].demandWh * 0.9 - ) { - logEvent("scenario_end", { - id: state.scenarioId, - type: "blackouts", - difficulty: state.difficulty, - }); - const summary = summarizeHistory(history); - setTimeout( - () => - store.dispatch( - dialogOpen({ - title: "Fired!", - message: `You've allowed chronic blackouts for 3 months, causing shareholders to remove you from office. - You survived for ${store.getState().game.date.year - store.getState().game.startingYear} years, - earned ${formatMoneyConcise(summary.revenue)} in revenue - and emitted ${numbro(summary.kgco2e / 1000).format({ thousandSeparated: true, mantissa: 0 })} tons of pollution.`, - open: true, - notCancellable: true, - actionLabel: "Try again", - action: () => store.dispatch(gameSlice.actions.quit()), - }) - ), - 1 - ); - } - - const scenario = - SCENARIOS.find((s) => s.id === state.scenarioId) || SCENARIOS[0]; - - // Success: Survived duration - if ( - state.date.monthsEllapsed === (scenario.durationMonths || 12 * 20) - ) { - const localStoragePlayed = ( - getStorageJson("plays", { plays: [] }) as any - ).plays as LocalStoragePlayedType[]; - setStorageKeyValue("plays", { - plays: [ - ...localStoragePlayed, - { - scenarioId: state.scenarioId, - date: new Date().toString(), - } as LocalStoragePlayedType, - ], - }); - - // Calculate score - This is also described in the manual; if I update the algorithm, update the manual too! - const summary = summarizeHistory(history); - const blackoutsTWh = - Math.max(0, summary.demandWh - summary.supplyWh) / 1000000000000; - // Scoring algorithm should also be updated in Game.tsx - const score = - scenario.ownership === "Investor" - ? { - supply: Math.round(summary.supplyWh / 1000000000000), - netWorth: Math.round((40 * summary.netWorth) / 1000000000), - customers: Math.round((2 * summary.customers) / 100000), - emissions: Math.round((-2 * summary.kgco2e) / 1000000000), - blackouts: Math.round(-8 * blackoutsTWh), - } - : { - supply: Math.round((10 * summary.supplyWh) / 1000000000000), - emissions: Math.round((-5 * summary.kgco2e) / 1000000000), - blackouts: Math.round(-10 * blackoutsTWh), - }; - - const finalScore = Object.values(score).reduce( - (a: number, b: number) => a + b - ); - const difficulty = state.difficulty; // pulling out of state for functions running inside of setTimeout - - if (!scenario.tutorialSteps) { - setTimeout( - () => - store.dispatch( - submitHighscore({ - score: finalScore, - scoreBreakdown: score, // For analytics purposes only - scenarioId: scenario.id, - difficulty, - }) - ), - 1 - ); - } - - logEvent("scenario_end", { - id: scenario.id, - type: "win", - difficulty, - score: finalScore, - }); - setTimeout( - () => - store.dispatch( - dialogOpen({ - title: scenario.endTitle || `You've retired!`, - message: scenario.endMessage || ( -
- Your final score is {finalScore}:
-
+{score.supply} pts from electricity supplied -
- {scenario.ownership === "Investor" && ( - - +{score.netWorth} pts from final net worth -
-
- )} - {scenario.ownership === "Investor" && ( - - +{score.customers} pts from final customers -
-
- )} - {score.emissions} pts from emissions -
- {score.blackouts} pts from blackouts -
-
- ), - open: true, - closeText: "Keep playing", - actionLabel: "Return to menu", - action: () => store.dispatch(gameSlice.actions.quit()), - }) - ), - 1 - ); - } - } + // update simulation if accumulated delta exceeds frame time + // calculate multiple simulation frames per render if on a slow device + let delta = performance.now() - previousTickMs; + while (delta > TICK_MS[state.speed]) { + tickState(state); + delta -= TICK_MS[state.speed]; + previousTickMs = performance.now(); } setTimeout( () => store.dispatch(gameSlice.actions.tick()), - TICK_MS[state.speed] + Math.max(1, TICK_MS[state.speed] - delta) ); }, delta: (state, action: PayloadAction>) => { @@ -480,6 +290,206 @@ export default gameSlice.reducer; // ====== HELPERS ====== +// Ticks the state forward in place +function tickState(state: GameType) { + state.date = getDateFromMinute( + state.date.minute + TICK_MINUTES, + state.startingYear + ); + const now = getTimeFromTimeline(state.date.minute, state.timeline); + const prev = getTimeFromTimeline( + state.date.minute - TICK_MINUTES, + state.timeline + ); + if (now && prev) { + updateSupplyFacilitiesFinances(state, prev, now); + + if (previousMonth !== state.date.month) { + previousMonth = state.date.month; + const history = state.monthlyHistory; + const { cash, customers } = now; + + // Record final history for the month, then generate the new timeline + history.unshift(summarizeTimeline(state.timeline, state.startingYear)); + state.timeline = generateNewTimeline(state, cash, customers); + + // Pre-roll a few frames to compensate for temperature / demand jumps across months + for (let i = 0; i < 4; i++) { + updateSupplyFacilitiesFinances( + state, + state.timeline[0], + state.timeline[0], + true + ); + } + + // ===== TRIGGERS ====== + // Failure: Bankrupt + if (now.cash < 0) { + logEvent("scenario_end", { + id: state.scenarioId, + type: "bankrupt", + difficulty: state.difficulty, + }); + const summary = summarizeHistory(history); + setTimeout( + () => + store.dispatch( + dialogOpen({ + title: "Bankrupt!", + message: `You've run out of money. + You survived for ${store.getState().game.date.year - store.getState().game.startingYear} years, + earned ${formatMoneyConcise(summary.revenue)} in revenue + and emitted ${numbro(summary.kgco2e / 1000).format({ thousandSeparated: true, mantissa: 0 })} tons of pollution.`, + open: true, + notCancellable: true, + actionLabel: "Try again", + action: () => store.dispatch(gameSlice.actions.quit()), + }) + ), + 1 + ); + } + + // Failure: Too many blackouts + if ( + history[1] && + history[2] && + history[3] && + history[1].supplyWh < history[1].demandWh * 0.9 && + history[2].supplyWh < history[2].demandWh * 0.9 && + history[3].supplyWh < history[3].demandWh * 0.9 + ) { + logEvent("scenario_end", { + id: state.scenarioId, + type: "blackouts", + difficulty: state.difficulty, + }); + const summary = summarizeHistory(history); + setTimeout( + () => + store.dispatch( + dialogOpen({ + title: "Fired!", + message: `You've allowed chronic blackouts for 3 months, causing shareholders to remove you from office. + You survived for ${store.getState().game.date.year - store.getState().game.startingYear} years, + earned ${formatMoneyConcise(summary.revenue)} in revenue + and emitted ${numbro(summary.kgco2e / 1000).format({ thousandSeparated: true, mantissa: 0 })} tons of pollution.`, + open: true, + notCancellable: true, + actionLabel: "Try again", + action: () => store.dispatch(gameSlice.actions.quit()), + }) + ), + 1 + ); + } + + const scenario = + SCENARIOS.find((s) => s.id === state.scenarioId) || SCENARIOS[0]; + + // Success: Survived duration + if (state.date.monthsEllapsed === (scenario.durationMonths || 12 * 20)) { + const localStoragePlayed = ( + getStorageJson("plays", { plays: [] }) as any + ).plays as LocalStoragePlayedType[]; + setStorageKeyValue("plays", { + plays: [ + ...localStoragePlayed, + { + scenarioId: state.scenarioId, + date: new Date().toString(), + } as LocalStoragePlayedType, + ], + }); + + // Calculate score - This is also described in the manual; if I update the algorithm, update the manual too! + const summary = summarizeHistory(history); + const blackoutsTWh = + Math.max(0, summary.demandWh - summary.supplyWh) / 1000000000000; + // Scoring algorithm should also be updated in Game.tsx + const score = + scenario.ownership === "Investor" + ? { + supply: Math.round(summary.supplyWh / 1000000000000), + netWorth: Math.round((40 * summary.netWorth) / 1000000000), + customers: Math.round((2 * summary.customers) / 100000), + emissions: Math.round((-2 * summary.kgco2e) / 1000000000), + blackouts: Math.round(-8 * blackoutsTWh), + } + : { + supply: Math.round((10 * summary.supplyWh) / 1000000000000), + emissions: Math.round((-5 * summary.kgco2e) / 1000000000), + blackouts: Math.round(-10 * blackoutsTWh), + }; + + const finalScore = Object.values(score).reduce( + (a: number, b: number) => a + b + ); + const difficulty = state.difficulty; // pulling out of state for functions running inside of setTimeout + + if (!scenario.tutorialSteps) { + setTimeout( + () => + store.dispatch( + submitHighscore({ + score: finalScore, + scoreBreakdown: score, // For analytics purposes only + scenarioId: scenario.id, + difficulty, + }) + ), + 1 + ); + } + + logEvent("scenario_end", { + id: scenario.id, + type: "win", + difficulty, + score: finalScore, + }); + setTimeout( + () => + store.dispatch( + dialogOpen({ + title: scenario.endTitle || `You've retired!`, + message: scenario.endMessage || ( +
+ Your final score is {finalScore}:
+
+{score.supply} pts from electricity supplied +
+ {scenario.ownership === "Investor" && ( + + +{score.netWorth} pts from final net worth +
+
+ )} + {scenario.ownership === "Investor" && ( + + +{score.customers} pts from final customers +
+
+ )} + {score.emissions} pts from emissions +
+ {score.blackouts} pts from blackouts +
+
+ ), + open: true, + closeText: "Keep playing", + actionLabel: "Return to menu", + action: () => store.dispatch(gameSlice.actions.quit()), + }) + ), + 1 + ); + } + } + } +} + // Simplified customer forecast, assumes no blackouts since supply calculation depends on demand (circular depedency) function getDemandW( date: DateType,