-
Notifications
You must be signed in to change notification settings - Fork 0
Implement basic playable Math Marauders game #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,29 @@ | ||
| TODO | ||
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <title>Math Marauders</title> | ||
| <style> | ||
| body { font-family: Arial, sans-serif; text-align: center; } | ||
| #choices button { margin: 0 10px; padding: 10px 20px; } | ||
| #popup { display: none; position: fixed; top: 40%; left: 50%; transform: translate(-50%, -50%); background: white; border: 1px solid #444; padding: 20px; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <h1>Math Marauders</h1> | ||
| <div id="hud"> | ||
| Soldiers: <span id="playerCount">0</span> | Enemy: <span id="enemyCount">0</span> | ||
| </div> | ||
| <div id="choices"> | ||
| <button id="leftChoice">A</button> | ||
| <button id="rightChoice">B</button> | ||
| </div> | ||
| <button id="startBtn">Play</button> | ||
| <div id="popup"> | ||
| <p id="popupText"></p> | ||
| <button id="nextBtn">Next</button> | ||
| <button id="retryBtn">Retry</button> | ||
| </div> | ||
| <script type="module" src="./src/index.js"></script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import WaveGenerator from './WaveGenerator.js'; | ||
|
|
||
| /** | ||
| * Resolves skirmishes and the final showdown. | ||
| */ | ||
| export default class BattleSystem { | ||
| /** | ||
| * Resolve a skirmish after a gate. | ||
| * @param {number} playerSize - current player army size | ||
| * @param {number} optimalSize - optimal army size at this point | ||
| * @returns {{enemy:number, result:number}} | ||
| */ | ||
| resolveSkirmish(playerSize, optimalSize) { | ||
| const enemy = Math.floor(optimalSize * WaveGenerator.SKIRMISH_FACTOR); | ||
| const result = Math.max(0, playerSize - enemy); | ||
| return { enemy, result }; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| /** | ||
| * Manages the player's army size. | ||
| */ | ||
| export default class FlockSystem { | ||
| constructor(initialSize = 10) { | ||
| this.size = initialSize; | ||
| } | ||
|
|
||
| /** | ||
| * Increase or decrease army size. | ||
| * @param {number} delta | ||
| */ | ||
| add(delta) { | ||
| this.size = Math.max(0, this.size + delta); | ||
| } | ||
|
|
||
| set(value) { | ||
| this.size = Math.max(0, value); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,94 @@ | ||||||||||
| import WaveGenerator from './WaveGenerator.js'; | ||||||||||
| import GateSystem from './GateSystem.js'; | ||||||||||
| import FlockSystem from './FlockSystem.js'; | ||||||||||
| import BattleSystem from './BattleSystem.js'; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Main game orchestrator. | ||||||||||
| */ | ||||||||||
| export default class GameController { | ||||||||||
| constructor(ui, persistence, telemetry) { | ||||||||||
| this.ui = ui; | ||||||||||
| this.persistence = persistence; | ||||||||||
| this.telemetry = telemetry; | ||||||||||
| this.waveGen = new WaveGenerator(); | ||||||||||
| this.battle = new BattleSystem(); | ||||||||||
| this.currentWave = 1; | ||||||||||
| this.ui.onStart(() => this.startWave()); | ||||||||||
| this.ui.onLeft(() => this.handleChoice(0)); | ||||||||||
| this.ui.onRight(() => this.handleChoice(1)); | ||||||||||
| this.ui.onNext(() => this.nextWave()); | ||||||||||
| this.ui.onRetry(() => this.retryWave()); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| startWave() { | ||||||||||
| this.ui.hidePopup(); | ||||||||||
| this.player = new FlockSystem(10); | ||||||||||
| const data = this.waveGen.generate(this.currentWave); | ||||||||||
| this.gateSystem = new GateSystem(data.gates); | ||||||||||
| this.optimalSize = this.player.size; | ||||||||||
| this.calculateOptimalPath(); | ||||||||||
| this.ui.updateCounts(this.player.size); | ||||||||||
| this.showCurrentGate(); | ||||||||||
| this.telemetry.trackEvent('wave_start', { wave: this.currentWave }); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| calculateOptimalPath() { | ||||||||||
| let size = this.player.size; | ||||||||||
| for (const gate of this.gateSystem.gates) { | ||||||||||
| const resA = gate[0].fn(size); | ||||||||||
| const resB = gate[1].fn(size); | ||||||||||
| size = Math.max(resA, resB); | ||||||||||
| size = Math.max(0, Math.floor(size)); | ||||||||||
| const enemy = Math.floor(size * WaveGenerator.SKIRMISH_FACTOR); | ||||||||||
| size = Math.max(0, size - enemy); | ||||||||||
|
Comment on lines
+43
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To avoid duplicating logic, use the
Suggested change
|
||||||||||
| } | ||||||||||
| this.optimalSize = size; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| showCurrentGate() { | ||||||||||
| if (!this.gateSystem.hasMore()) { | ||||||||||
| return this.endWave(); | ||||||||||
| } | ||||||||||
| const gate = this.gateSystem.currentGate(); | ||||||||||
| this.ui.updateGateLabels(gate[0].label, gate[1].label); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| handleChoice(index) { | ||||||||||
| if (!this.gateSystem.hasMore()) return; | ||||||||||
| const optimal = this.gateSystem.optimalOutcome(this.player.size); | ||||||||||
| const newSize = this.gateSystem.applyChoice(index, this.player.size); | ||||||||||
| this.player.set(newSize); | ||||||||||
| const { enemy, result } = this.battle.resolveSkirmish(this.player.size, optimal); | ||||||||||
| this.player.set(result); | ||||||||||
| this.ui.updateCounts(this.player.size, enemy); | ||||||||||
| this.showCurrentGate(); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| endWave() { | ||||||||||
| const stars = this.computeStars(); | ||||||||||
| if (stars > this.persistence.getStarRating(this.currentWave)) { | ||||||||||
| this.persistence.setStarRating(this.currentWave, stars); | ||||||||||
| } | ||||||||||
| this.ui.showPopup(`Wave ${this.currentWave} Complete`, stars); | ||||||||||
| this.telemetry.trackEvent('wave_complete', { wave: this.currentWave, stars }); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| computeStars() { | ||||||||||
| const ratio = this.optimalSize === 0 ? 0 : this.player.size / this.optimalSize; | ||||||||||
| if (ratio <= 0.4) return 1; | ||||||||||
| if (ratio <= 0.6) return 2; | ||||||||||
| if (ratio <= 0.75) return 3; | ||||||||||
| if (ratio <= 0.9) return 4; | ||||||||||
| return 5; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| nextWave() { | ||||||||||
| this.currentWave += 1; | ||||||||||
| this.startWave(); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| retryWave() { | ||||||||||
| this.startWave(); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| /** | ||
| * Handles math gates and player choices. | ||
| */ | ||
| export default class GateSystem { | ||
| constructor(gates = []) { | ||
| this.gates = gates; | ||
| this.index = 0; | ||
| } | ||
|
|
||
| currentGate() { | ||
| return this.gates[this.index]; | ||
| } | ||
|
|
||
| /** | ||
| * Apply player's choice to the army size. | ||
| * @param {number} choiceIndex | ||
| * @param {number} armySize | ||
| * @returns {number} new army size | ||
| */ | ||
| applyChoice(choiceIndex, armySize) { | ||
| const gate = this.currentGate(); | ||
| const op = gate[choiceIndex]; | ||
| this.index++; | ||
| return op.fn(armySize); | ||
| } | ||
|
|
||
| /** | ||
| * Compute optimal army size after this gate. | ||
| * @param {number} armySize | ||
| * @returns {number} | ||
| */ | ||
| optimalOutcome(armySize) { | ||
| const gate = this.currentGate(); | ||
| const resA = gate[0].fn(armySize); | ||
| const resB = gate[1].fn(armySize); | ||
| return Math.max(resA, resB); | ||
| } | ||
|
|
||
| hasMore() { | ||
| return this.index < this.gates.length; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| /** | ||
| * Handles saving and loading star ratings using localStorage. | ||
| */ | ||
| export default class PersistenceManager { | ||
| constructor(storage = (typeof window !== 'undefined' ? window.localStorage : null)) { | ||
| this.storage = storage; | ||
| this.prefix = 'mathMarauders_wave_'; | ||
| } | ||
|
|
||
| /** | ||
| * Get stored star rating for a wave. | ||
| * @param {number} wave | ||
| * @returns {number} | ||
| */ | ||
| getStarRating(wave) { | ||
| if (!this.storage) return 0; | ||
| const value = this.storage.getItem(this.prefix + wave); | ||
| return value ? parseInt(value, 10) : 0; | ||
| } | ||
|
|
||
| /** | ||
| * Save star rating for a wave. | ||
| * @param {number} wave | ||
| * @param {number} stars | ||
| */ | ||
| setStarRating(wave, stars) { | ||
| if (!this.storage) return; | ||
| this.storage.setItem(this.prefix + wave, String(stars)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| /** | ||
| * Default Telemetry implementation. | ||
| * Logs events to the console. Future versions may send data to a server. | ||
| */ | ||
| export default class Telemetry { | ||
| /** | ||
| * Track a telemetry event. | ||
| * @param {string} name - Event name. | ||
| * @param {object} payload - Additional data. | ||
| */ | ||
| trackEvent(name, payload = {}) { | ||
| console.log(`[Telemetry] ${name}`, payload); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| /** | ||
| * Handles DOM updates and user interactions. | ||
| */ | ||
| export default class UIManager { | ||
| constructor() { | ||
| this.root = document.getElementById('game'); | ||
| this.startBtn = document.getElementById('startBtn'); | ||
| this.leftBtn = document.getElementById('leftChoice'); | ||
| this.rightBtn = document.getElementById('rightChoice'); | ||
| this.playerCountEl = document.getElementById('playerCount'); | ||
| this.enemyCountEl = document.getElementById('enemyCount'); | ||
| this.popup = document.getElementById('popup'); | ||
| this.popupText = document.getElementById('popupText'); | ||
| this.nextBtn = document.getElementById('nextBtn'); | ||
| this.retryBtn = document.getElementById('retryBtn'); | ||
| } | ||
|
|
||
| onStart(cb) { | ||
| this.startBtn.addEventListener('click', cb); | ||
| } | ||
| onLeft(cb) { | ||
| this.leftBtn.addEventListener('click', cb); | ||
| } | ||
| onRight(cb) { | ||
| this.rightBtn.addEventListener('click', cb); | ||
| } | ||
| onNext(cb) { | ||
| this.nextBtn.addEventListener('click', cb); | ||
| } | ||
| onRetry(cb) { | ||
| this.retryBtn.addEventListener('click', cb); | ||
| } | ||
|
|
||
| updateGateLabels(a, b) { | ||
| this.leftBtn.textContent = a; | ||
| this.rightBtn.textContent = b; | ||
| } | ||
|
|
||
| updateCounts(player, enemy = 0) { | ||
| this.playerCountEl.textContent = player; | ||
| this.enemyCountEl.textContent = enemy; | ||
| } | ||
|
|
||
| showPopup(text, stars) { | ||
| this.popupText.textContent = `${text}\n${'★'.repeat(stars)}${'☆'.repeat(5 - stars)}`; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| this.popup.style.display = 'block'; | ||
| } | ||
|
|
||
| hidePopup() { | ||
| this.popup.style.display = 'none'; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| /** | ||
| * Procedurally generates wave configurations including gates and enemy sizes. | ||
| */ | ||
| export default class WaveGenerator { | ||
| static SKIRMISH_FACTOR = 0.8; | ||
|
|
||
| /** | ||
| * Calculate number of gates for a wave. | ||
| * Wave 1 = 5 gates and each subsequent wave adds 1. | ||
| * @param {number} wave | ||
| * @returns {number} | ||
| */ | ||
| static gateCount(wave) { | ||
| return 4 + wave; | ||
| } | ||
|
|
||
| /** | ||
| * Generate a wave configuration. | ||
| * @param {number} wave | ||
| * @returns {{gates:Array, retreatGateCount:number}} | ||
| */ | ||
| generate(wave) { | ||
| const count = WaveGenerator.gateCount(wave); | ||
| const gates = []; | ||
| for (let i = 0; i < count; i++) { | ||
| gates.push(this._generateGateOps(wave)); | ||
| } | ||
| return { gates, retreatGateCount: count }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
|
|
||
| _rand(min, max) { | ||
| return Math.floor(Math.random() * (max - min + 1)) + min; | ||
| } | ||
|
|
||
| _opLabelAndFn(op, value) { | ||
| switch (op) { | ||
| case '+': | ||
| return { label: `+${value}`, fn: x => x + value }; | ||
| case '-': | ||
| return { label: `-${value}`, fn: x => x - value }; | ||
| case '*': | ||
| return { label: `x${value}`, fn: x => x * value }; | ||
| case '/': | ||
| return { label: `/${value}`, fn: x => Math.floor(x / value) }; | ||
| default: | ||
| return { label: '', fn: x => x }; | ||
| } | ||
| } | ||
|
|
||
| _generateOneStep() { | ||
| const ops = ['+', '-', '*', '/']; | ||
| const op = ops[this._rand(0, ops.length - 1)]; | ||
| const val = this._rand(2, 5); | ||
| return this._opLabelAndFn(op, val); | ||
| } | ||
|
|
||
| _generateTwoStep() { | ||
| const first = this._generateOneStep(); | ||
| const second = this._generateOneStep(); | ||
| return { | ||
| label: `${first.label}${second.label}`, | ||
| fn: x => second.fn(first.fn(x)) | ||
| }; | ||
| } | ||
|
|
||
| _generateGateOps(wave) { | ||
| const tierTwo = wave >= 6 && wave <= 10; | ||
| const tierThree = wave >= 11; | ||
| const opA = tierTwo || tierThree ? this._generateTwoStep() : this._generateOneStep(); | ||
| const opB = tierThree ? this._generateTwoStep() : this._generateOneStep(); | ||
| return [opA, opB]; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To ensure the number of soldiers is always an integer, use
Math.floorwhen adding to the size.