commit
53f3da48bc
14 changed files with 5024 additions and 0 deletions
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
import { Phase, PlayerActions } from "./enums"; |
||||
import { assert } from "./utils"; |
||||
|
||||
const valid_actions = Object.values(PlayerActions); |
||||
|
||||
class InputHandler { |
||||
/** |
||||
* Handles input relating to the player's game actions. |
||||
* @class |
||||
* @property {import('./game.js').GameState} game_state - The game state this handler manipulates |
||||
* @property {import('./Time.js').Time} time - The time manager for the current game loop |
||||
*
|
||||
* The InputHandler manages player events (clicks, keystrokes, etc.). |
||||
* It validates actions, updates game state, and interacts with the time system as needed. |
||||
*/ |
||||
game_state; |
||||
time; |
||||
constructor(game_state, time) { |
||||
this.game_state = game_state; |
||||
this.time = time; |
||||
} |
||||
|
||||
setup() { |
||||
document.addEventListener("click", this.handleClickEvent); |
||||
} |
||||
|
||||
/** |
||||
* Click handler |
||||
* @param {Event} e
|
||||
* @returns
|
||||
*/ |
||||
handleClickEvent(e) { |
||||
if (!e.target) return; |
||||
const el = /** @type {Element} */ (e.target) |
||||
|
||||
const target = /** @type {HTMLElement | null} */ (el.closest("[data-action]")); |
||||
assert(!!target, "there must be a valid target"); |
||||
|
||||
const action = target?.dataset?.action ?? ""; |
||||
switch (action) { |
||||
case PlayerActions.OPEN: |
||||
this.game_state.time.phase = Phase.OPEN; |
||||
break; |
||||
case PlayerActions.CLOSE: |
||||
this.game_state.time.phase = Phase.CLOSE; |
||||
break; |
||||
|
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
import { Phase } from "./enums"; |
||||
import { assert } from "./utils"; |
||||
|
||||
export class PlanDayCommand { |
||||
/** |
||||
* Executes the plan day command |
||||
* @param {import(".").GameState} game_state |
||||
* @returns {import(".").GameState} |
||||
*/ |
||||
execute(game_state) { |
||||
assert( |
||||
game_state.time.phase === Phase.CLOSE, |
||||
"store must be closed before you can plan", |
||||
); |
||||
|
||||
return { |
||||
...game_state, |
||||
time: { |
||||
...game_state.time, |
||||
day: game_state.time.day + 1, |
||||
phase: Phase.PLANNING, |
||||
}, |
||||
}; |
||||
} |
||||
} |
||||
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
import { Phase } from "./enums.js"; |
||||
import { PlanDayCommand } from "./StartDayCommand.js"; |
||||
import { assert } from "./utils.js"; |
||||
|
||||
/** |
||||
* @typedef {import('./game.js').GameState} GameState |
||||
*/ |
||||
|
||||
/** |
||||
* Manages a single day in the bakery game |
||||
*/ |
||||
export class Time { |
||||
/** @type {GameState} */ |
||||
game_state; |
||||
|
||||
/** @type {import("./game.js").Command[]} */ |
||||
events; |
||||
|
||||
/** |
||||
* @param {GameState} game_state - The current game state |
||||
*/ |
||||
constructor(game_state) { |
||||
this.game_state = game_state; |
||||
this.events = []; |
||||
} |
||||
|
||||
/** |
||||
* Queues a command for execution |
||||
* @param {import("./game.js").Command} command |
||||
*/ |
||||
queue(command) { |
||||
this.events.push(command); |
||||
} |
||||
|
||||
/** |
||||
* Updates the day timer |
||||
* @param {number} dt - Delta time in milliseconds |
||||
*/ |
||||
update(dt) { |
||||
this.game_state.time.elapsed_ms += dt; |
||||
for (const command of this.events) { |
||||
this.game_state = command.execute(this.game_state); |
||||
} |
||||
} |
||||
|
||||
/** Renders the current day state */ |
||||
render() {} |
||||
} |
||||
|
||||
export { Day }; |
||||
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
export const Phase = Object.freeze({ |
||||
PLANNING: "planning", |
||||
OPEN: "open", |
||||
CLOSE: "close", |
||||
}); |
||||
|
||||
export const MenuItemState = Object.freeze({ |
||||
ENABLED: "enabled", |
||||
DISABLED: "disabled", |
||||
}); |
||||
|
||||
export const PlayerActions = Object.freeze({ |
||||
PLAN: "day", |
||||
OPEN: "day", |
||||
CLOSE: "day", |
||||
}); |
||||
@ -0,0 +1,249 @@
@@ -0,0 +1,249 @@
|
||||
import { MenuItemState } from "./enums.js"; |
||||
import { assert } from "./utils.js"; |
||||
|
||||
/** |
||||
* @typedef {Object} SuppliesBase |
||||
* @property {number} flour |
||||
* @property {number} eggs |
||||
* @property {number} oil |
||||
* @property {number} butter |
||||
* @property {number} milk |
||||
* @property {number} yeast |
||||
* @property {number} chocolate |
||||
* @property {number} fruit |
||||
* @property {number} sugar |
||||
*/ |
||||
|
||||
/** @typedef {SuppliesBase & {[key: string]: number}} Supplies */ |
||||
|
||||
/** |
||||
* @typedef {'enabled' | 'disabled'} MenuItemStateValue |
||||
*/ |
||||
|
||||
/** |
||||
* @typedef {Object} MenuItem |
||||
* @property {Partial<Supplies>} ingredients |
||||
* @property {number} oven_slots |
||||
* @property {number} base_demand |
||||
* @property {MenuItemStateValue} state |
||||
*/ |
||||
|
||||
/** |
||||
* @typedef {'bread' | 'sweet_rolls' | 'vanilla_cake' | 'cookies' | 'fruit_muffins' | 'croissants' | 'chocolate_cookies' | 'chocolate_cake' | 'fruit_tart'} MenuItemName |
||||
*/ |
||||
|
||||
/** |
||||
* @typedef {Object} GameState |
||||
* @property {number} player_money |
||||
* @property {{ day: number, phase: string, elapsed_ms: number, day_length_ms: number, tick_ms: number }} time |
||||
* @property {Supplies} supplies |
||||
* @property {Record<MenuItemName, MenuItem>} menu |
||||
* @property {Supplies} store |
||||
* @property {{ capacity: number, used: number, queue: string[] }} oven |
||||
* @property {Record<MenuItemName, number>} prices |
||||
*/ |
||||
|
||||
/** |
||||
* @typedef {Object} Command |
||||
* @property {(game_state: GameState) => GameState} execute |
||||
*/ |
||||
|
||||
/** |
||||
* Creates a new game state with default values |
||||
* @returns {GameState} |
||||
*/ |
||||
function init_game_state() { |
||||
return { |
||||
player_money: 0, |
||||
time: { |
||||
day: 1, |
||||
phase: "planning", |
||||
elapsed_ms: 0, |
||||
day_length_ms: 30000, |
||||
tick_ms: 250, |
||||
}, |
||||
supplies: { |
||||
flour: 0, |
||||
eggs: 0, |
||||
oil: 0, |
||||
butter: 0, |
||||
milk: 0, |
||||
yeast: 0, |
||||
chocolate: 0, |
||||
fruit: 0, |
||||
sugar: 0, |
||||
}, |
||||
menu: { |
||||
bread: { |
||||
ingredients: { |
||||
flour: 2, |
||||
yeast: 1, |
||||
milk: 1, |
||||
}, |
||||
oven_slots: 1, |
||||
base_demand: 40, |
||||
state: MenuItemState.ENABLED, |
||||
}, |
||||
sweet_rolls: { |
||||
ingredients: { |
||||
flour: 2, |
||||
sugar: 1, |
||||
butter: 1, |
||||
milk: 1, |
||||
}, |
||||
oven_slots: 1, |
||||
base_demand: 35, |
||||
state: MenuItemState.ENABLED, |
||||
}, |
||||
vanilla_cake: { |
||||
ingredients: { |
||||
flour: 2, |
||||
eggs: 2, |
||||
sugar: 2, |
||||
milk: 1, |
||||
oil: 1, |
||||
}, |
||||
oven_slots: 2, |
||||
base_demand: 25, |
||||
state: MenuItemState.ENABLED, |
||||
}, |
||||
cookies: { |
||||
ingredients: { |
||||
flour: 1, |
||||
eggs: 1, |
||||
sugar: 1, |
||||
butter: 1, |
||||
}, |
||||
oven_slots: 2, |
||||
base_demand: 40, |
||||
state: MenuItemState.ENABLED, |
||||
}, |
||||
fruit_muffins: { |
||||
ingredients: { |
||||
flour: 2, |
||||
eggs: 1, |
||||
sugar: 1, |
||||
fruit: 1, |
||||
}, |
||||
oven_slots: 2, |
||||
base_demand: 30, |
||||
state: MenuItemState.ENABLED, |
||||
}, |
||||
croissants: { |
||||
ingredients: { |
||||
flour: 2, |
||||
butter: 2, |
||||
milk: 1, |
||||
yeast: 1, |
||||
}, |
||||
oven_slots: 3, |
||||
base_demand: 20, |
||||
state: MenuItemState.ENABLED, |
||||
}, |
||||
chocolate_cookies: { |
||||
ingredients: { |
||||
flour: 1, |
||||
eggs: 1, |
||||
sugar: 1, |
||||
butter: 1, |
||||
chocolate: 1, |
||||
}, |
||||
oven_slots: 2, |
||||
base_demand: 35, |
||||
state: MenuItemState.ENABLED, |
||||
}, |
||||
chocolate_cake: { |
||||
ingredients: { |
||||
flour: 2, |
||||
eggs: 2, |
||||
sugar: 2, |
||||
butter: 1, |
||||
chocolate: 2, |
||||
milk: 1, |
||||
}, |
||||
oven_slots: 3, |
||||
base_demand: 12, |
||||
state: MenuItemState.ENABLED, |
||||
}, |
||||
fruit_tart: { |
||||
ingredients: { |
||||
flour: 2, |
||||
butter: 2, |
||||
sugar: 1, |
||||
eggs: 1, |
||||
fruit: 2, |
||||
}, |
||||
oven_slots: 4, |
||||
base_demand: 15, |
||||
state: MenuItemState.ENABLED, |
||||
}, |
||||
}, |
||||
store: { |
||||
flour: 0.6, |
||||
sugar: 0.4, |
||||
yeast: 0.3, |
||||
oil: 0.7, |
||||
milk: 0.8, |
||||
eggs: 0.9, |
||||
butter: 1.2, |
||||
fruit: 1.3, |
||||
chocolate: 1.5, |
||||
}, |
||||
oven: { |
||||
capacity: 4, |
||||
used: 0, |
||||
queue: [], |
||||
}, |
||||
prices: { |
||||
bread: 4.0, |
||||
sweet_rolls: 5.0, |
||||
vanilla_cake: 9.0, |
||||
cookies: 6.0, |
||||
fruit_muffins: 6.5, |
||||
croissants: 7.5, |
||||
chocolate_cookies: 7.0, |
||||
chocolate_cake: 14.0, |
||||
fruit_tart: 12.0, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Bakes an item if supplies and oven capacity allow |
||||
* @param {GameState} game_state - The current game state |
||||
* @param {MenuItemName} item_name - The name of the item to bake |
||||
* @returns {GameState} The updated game state |
||||
*/ |
||||
function bake_items(game_state, item_name) { |
||||
const menu_item = game_state.menu[item_name]; |
||||
const oven_slots = menu_item.oven_slots; |
||||
const canFitInOven = |
||||
game_state.oven.used + oven_slots <= game_state.oven.capacity; |
||||
|
||||
assert(canFitInOven, "oven must have capacity before baking"); |
||||
assert(!!game_state, "game_state must be defined"); |
||||
assert( |
||||
game_state.menu[item_name].state === MenuItemState.ENABLED, |
||||
"item must be bakeable with current supplies", |
||||
); |
||||
|
||||
// supplies update
|
||||
const updated_supplies = { |
||||
...game_state.supplies, |
||||
}; |
||||
|
||||
for (const ingredient in menu_item.ingredients) { |
||||
updated_supplies[ingredient] -= menu_item.ingredients[ingredient] ?? 0; |
||||
} |
||||
|
||||
return { |
||||
...game_state, |
||||
supplies: updated_supplies, |
||||
oven: { |
||||
...game_state.oven, |
||||
used: game_state.oven.used + oven_slots, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
export { init_game_state, bake_items }; |
||||
@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
|
||||
import { init_game, bake_items, is_menu_item_enabled } from './game.js'; |
||||
|
||||
describe('init_game', () => { |
||||
test('should create a new game state with default values', () => { |
||||
const game = init_game(); |
||||
|
||||
expect(game.player_money).toBe(0); |
||||
expect(game.time.day).toBe(1); |
||||
expect(game.time.phase).toBe('planning'); |
||||
expect(game.oven.capacity).toBe(4); |
||||
expect(game.oven.used).toBe(0); |
||||
}); |
||||
|
||||
test('should start with zero supplies', () => { |
||||
const game = init_game(); |
||||
|
||||
expect(game.supplies.flour).toBe(0); |
||||
expect(game.supplies.eggs).toBe(0); |
||||
expect(game.supplies.sugar).toBe(0); |
||||
}); |
||||
}); |
||||
|
||||
describe('is_menu_item_enabled', () => { |
||||
test('should return false when supplies are insufficient', () => { |
||||
const game = init_game(); |
||||
// No supplies, so nothing should be enabled
|
||||
expect(is_menu_item_enabled(game, 'bread')).toBe(false); |
||||
expect(is_menu_item_enabled(game, 'cookies')).toBe(false); |
||||
}); |
||||
|
||||
test('should return true when supplies are sufficient', () => { |
||||
const game = init_game(); |
||||
// Add enough supplies for bread (flour: 2, yeast: 1, milk: 1)
|
||||
game.supplies.flour = 10; |
||||
game.supplies.yeast = 5; |
||||
game.supplies.milk = 5; |
||||
|
||||
expect(is_menu_item_enabled(game, 'bread')).toBe(true); |
||||
}); |
||||
|
||||
test('should return false for non-existent menu item', () => { |
||||
const game = init_game(); |
||||
expect(is_menu_item_enabled(game, 'pizza')).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('bake_items', () => { |
||||
test('should deduct ingredients when baking', () => { |
||||
const game = init_game(); |
||||
// Add supplies for bread
|
||||
game.supplies.flour = 10; |
||||
game.supplies.yeast = 5; |
||||
game.supplies.milk = 5; |
||||
|
||||
const result = bake_items(game, 'bread'); |
||||
|
||||
// Bread uses: flour: 2, yeast: 1, milk: 1
|
||||
expect(result.supplies.flour).toBe(8); |
||||
expect(result.supplies.yeast).toBe(4); |
||||
expect(result.supplies.milk).toBe(4); |
||||
}); |
||||
|
||||
test('should increase oven used slots', () => { |
||||
const game = init_game(); |
||||
game.supplies.flour = 10; |
||||
game.supplies.yeast = 5; |
||||
game.supplies.milk = 5; |
||||
|
||||
const result = bake_items(game, 'bread'); |
||||
|
||||
// Bread uses 1 oven slot
|
||||
expect(result.oven.used).toBe(1); |
||||
}); |
||||
|
||||
test('should not mutate the original game state', () => { |
||||
const game = init_game(); |
||||
game.supplies.flour = 10; |
||||
game.supplies.yeast = 5; |
||||
game.supplies.milk = 5; |
||||
|
||||
bake_items(game, 'bread'); |
||||
|
||||
// Original should be unchanged
|
||||
expect(game.supplies.flour).toBe(10); |
||||
expect(game.oven.used).toBe(0); |
||||
}); |
||||
}); |
||||
|
||||
|
||||
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
<html> |
||||
<head> |
||||
<title>Bakery</title> |
||||
</head> |
||||
<body> |
||||
<h1>Bake Shop</h1> |
||||
|
||||
<button id="start_day">Start Day</button> |
||||
|
||||
<div class="dashboard"> |
||||
<div class="game_section"> |
||||
<h2>Supplies</h2> |
||||
<ul> |
||||
<li>flour: <span data-binding="supplies.flour"></span></li> |
||||
<li>eggs: <span data-binding="supplies.eggs"></span></li> |
||||
<li>oil: <span data-binding="supplies.oil"></span></li> |
||||
<li>butter: <span data-binding="supplies.butter"></span></li> |
||||
<li>milk: <span data-binding="supplies.milk"></span></li> |
||||
<li>yeast: <span data-binding="supplies.yeast"></span></li> |
||||
<li>chocolate: <span data-binding="supplies.chocolate"></span></li> |
||||
<li>fruit: <span data-binding="supplies.fruit"></span></li> |
||||
<li>sugar: <span data-binding="supplies.sugar"></span></li> |
||||
</ul> |
||||
</div> |
||||
<!-- game_section --> |
||||
</div> |
||||
<!-- dashboard --> |
||||
</body> |
||||
<script src="index.js" type="module"></script> |
||||
</html> |
||||
@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
|
||||
import { Time } from "./Time.js"; |
||||
import { init_game_state } from "./game.js"; |
||||
|
||||
/** |
||||
* @typedef {import('./game.js').GameState} GameState |
||||
*/ |
||||
|
||||
/** @type {GameState} */ |
||||
let game_state = init_game_state(); |
||||
let time = new Time(game_state); |
||||
|
||||
/** @type {number} */ |
||||
let last = performance.now(); |
||||
|
||||
/** |
||||
* Main game loop - runs every frame |
||||
* @param {number} now - Current timestamp from requestAnimationFrame |
||||
*/ |
||||
function loop(now) { |
||||
const dt = now - last; |
||||
last = now; |
||||
|
||||
update(dt); |
||||
|
||||
render(); |
||||
|
||||
requestAnimationFrame(loop); |
||||
} |
||||
|
||||
/** |
||||
* Updates game state based on elapsed time |
||||
* @param {number} dt - Delta time in milliseconds |
||||
*/ |
||||
function update(dt) { |
||||
time.update(dt); |
||||
} |
||||
|
||||
/** |
||||
* Renders the current game state |
||||
*/ |
||||
function render() { |
||||
//render stuff here
|
||||
} |
||||
|
||||
requestAnimationFrame(loop); |
||||
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
export default { |
||||
// Use the Node.js test environment
|
||||
testEnvironment: 'node', |
||||
|
||||
// Look for test files in these patterns
|
||||
testMatch: [ |
||||
'**/__tests__/**/*.js', |
||||
'**/*.test.js' |
||||
], |
||||
|
||||
// Enable verbose output so you can see each test
|
||||
verbose: true, |
||||
}; |
||||
|
||||
|
||||
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
{ |
||||
"compilerOptions": { |
||||
"checkJs": true, |
||||
"strict": true, |
||||
"noImplicitAny": true, |
||||
"strictNullChecks": true, |
||||
"moduleResolution": "node", |
||||
"module": "ES2022", |
||||
"target": "ES2022" |
||||
}, |
||||
"include": ["*.js"], |
||||
"exclude": ["node_modules", "*.test.js"] |
||||
} |
||||
|
||||
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
{ |
||||
"name": "bakery", |
||||
"version": "1.0.0", |
||||
"type": "module", |
||||
"main": "index.js", |
||||
"scripts": { |
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" |
||||
}, |
||||
"keywords": [], |
||||
"author": "", |
||||
"license": "ISC", |
||||
"description": "", |
||||
"devDependencies": { |
||||
"jest": "^30.2.0" |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
/** |
||||
* Asserts a condition is true, throws and breaks the game if not |
||||
* @param {boolean} condition - The condition to check |
||||
* @param {string} message - Error message if assertion fails |
||||
* @throws {Error} Throws an error if the condition is false |
||||
*/ |
||||
export function assert(condition, message) { |
||||
if (!condition) { |
||||
const error = new Error(`Assertion failed: ${message}`); |
||||
console.error(error); |
||||
throw error; |
||||
} |
||||
} |
||||
|
||||
Loading…
Reference in new issue