Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,5 +133,4 @@ A wave-based runner where the player leads a flock of soldiers through a series

## 10. Analytics & Telemetry

* **Abstract `Telemetry` interface** with methods like `trackEvent(name, payload)`.
* Default implementation logs to console; ready for remote analytics plug-in in future.
* **Abstract `Telemetry` interface** with methods like `trackEvent(name, payload)`.* Default implementation logs to console; ready for remote analytics plug-in in future.\n---\n## Running the Game\nOpen index.html in a modern browser supporting ES modules.\n\n## Running Tests\nRun `npm install` and then `npm test`.\n
30 changes: 29 additions & 1 deletion index.html
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>
18 changes: 18 additions & 0 deletions src/game/BattleSystem.js
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 };
}
}
20 changes: 20 additions & 0 deletions src/game/FlockSystem.js
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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To ensure the number of soldiers is always an integer, use Math.floor when adding to the size.

Suggested change
this.size = Math.max(0, this.size + delta);
this.size = Math.max(0, Math.floor(this.size + delta));

}

set(value) {
this.size = Math.max(0, value);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To ensure the number of soldiers is always an integer, use Math.floor when setting the size.

Suggested change
this.size = Math.max(0, value);
this.size = Math.max(0, Math.floor(value));

}
}
94 changes: 94 additions & 0 deletions src/game/GameController.js
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid duplicating logic, use the BattleSystem.resolveSkirmish method to calculate the skirmish result. Pass the current size as both playerSize and optimalSize since this is for optimal path calculation.

Suggested change
const enemy = Math.floor(size * WaveGenerator.SKIRMISH_FACTOR);
size = Math.max(0, size - enemy);
const { result } = this.battle.resolveSkirmish(size, size);
size = result;

}
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();
}
}
42 changes: 42 additions & 0 deletions src/game/GateSystem.js
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;
}
}
30 changes: 30 additions & 0 deletions src/game/PersistenceManager.js
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));
}
}
14 changes: 14 additions & 0 deletions src/game/Telemetry.js
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);
}
}
52 changes: 52 additions & 0 deletions src/game/UIManager.js
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)}`;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use <br> tag to ensure the stars are displayed on a new line.

Suggested change
this.popupText.textContent = `${text}\n${'★'.repeat(stars)}${'☆'.repeat(5 - stars)}`;
this.popupText.innerHTML = `${text}<br>${'★'.repeat(stars)}${'☆'.repeat(5 - stars)}`;

this.popup.style.display = 'block';
}

hidePopup() {
this.popup.style.display = 'none';
}
}
73 changes: 73 additions & 0 deletions src/game/WaveGenerator.js
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 };

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The retreatGateCount is not used. Remove it to simplify the return.

Suggested change
return { gates, retreatGateCount: count };
return { gates };

}

_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];
}
}
Loading