-
Notifications
You must be signed in to change notification settings - Fork 0
feat(core): prototype loop assembly [#iteration-01] #8
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?
Conversation
Summary of ChangesHello @cs-util, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request delivers the initial prototype for the 'Math Marauders' game, establishing the foundational gameplay loop and user interface. It integrates core game logic for math-based gate selection, combat simulation, and a chase sequence, all designed for a mobile-first experience. The changes also include robust testing infrastructure and documentation to guide future development, ensuring a solid base for further iterations. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
Summary of ChangesHello @cs-util, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request delivers a playable, mobile-friendly prototype of "Math Marauders," establishing the core gameplay mechanics and user interface. It lays a robust foundation for future development by integrating a comprehensive testing suite, performance optimization features, and detailed documentation of the implementation progress. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
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.
💡 Codex Review
Here are some automated review suggestions for this pull request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting
| const isPaused = !this.state.isPaused; | ||
| if (isPaused) { | ||
| this.resetTimers(); | ||
| } else { | ||
| this.startTimer(); |
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.
Preserve queued phase transitions when pausing
Toggling pause calls resetTimers() which clears both the elapsed timer and pendingPhaseTimeout, but resuming only restarts the interval timer. If the user pauses during the 350 ms/400 ms/300 ms delays that advance the loop (forward→skirmish, skirmish→reverse, reverse→finish), the scheduled timeout is cancelled and never re‑created, leaving the run stuck in that phase with no score card. The pause handler should either avoid clearing the pending timeout or re‑schedule the appropriate phase when unpausing so pausing cannot break the game flow.
Useful? React with 👍 / 👎.
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.
Code Review
This is an impressive pull request that stands up a comprehensive prototype for the Math Marauders game. The code is well-structured into modules for game logic, UI rendering, and utilities, establishing a solid foundation. The inclusion of a full test suite with unit, integration, and property-based tests from the outset is excellent practice. My review focuses on a few areas in the core game logic and state management that could be improved for robustness and to align more closely with the specifications.
| const enemyCount = Math.max( | ||
| 6, | ||
| Math.round(this.state.units.current / ENEMY_RATIO) | ||
| ); |
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.
The calculation for enemyCount appears to be the inverse of what's described in the README.md spec. The spec mentions enemy squads are "~80% of the optimal player count", but the current logic (this.state.units.current / ENEMY_RATIO where ENEMY_RATIO is 0.8) calculates 125% of the player's units. This will likely make the skirmishes significantly harder than intended. The calculation should probably be a multiplication instead of a division.
| const enemyCount = Math.max( | |
| 6, | |
| Math.round(this.state.units.current / ENEMY_RATIO) | |
| ); | |
| const enemyCount = Math.max( | |
| 6, | |
| Math.round(this.state.units.current * ENEMY_RATIO) | |
| ); |
| restart() { | ||
| if (this.state.phase === 'idle') { | ||
| this.start(this.state.seed); | ||
| } else { | ||
| this.start(this.state.seed + '-rerun'); | ||
| } | ||
| } |
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.
The current method for generating a new seed on restart by appending '-rerun' can lead to long and brittle seed strings (e.g., seed-rerun-rerun-rerun) after multiple retries. A more robust approach would be to use a counter for retries, which would keep the seed predictable and clean. This would involve adding a retries counter to your game state, which is reset in start() and incremented in restart().
restart() {
if (this.state.phase === 'idle') {
this.start(this.state.seed);
} else {
const baseSeed = this.state.seed.split('-rerun-')[0];
const retryCount = (this.state.retries || 0) + 1;
this.state.retries = retryCount;
this.start(`${baseSeed}-rerun-${retryCount}`);
}
}| divide: { symbol: '÷', color: 'hazard' }, | ||
| }; | ||
|
|
||
| let gateIdCounter = 0; |
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.
The gateIdCounter is a module-level variable. This will be shared across all instances of GameEngine if you ever create more than one, leading to potential ID collisions and unpredictable behavior. It's better to manage this counter within the GameEngine state to ensure each instance is isolated.
For example, you could manage the counter in the engine and pass it to generateGateSet:
// in gameEngine.js
// ... in start()
this.gateIdCounter = 0;
// ... in chooseGate()
current: generateGateSet({ ..., gateIdCounter: this.gateIdCounter++ })
// in gates.js
export function generateGateSet({ wave, ..., gateIdCounter }) {
// ...
return {
id: `gate-${wave}-${gateIdCounter}`,
// ...
}
}| unitsDelta.classList.add('delta--active'); | ||
| setTimeout(() => { | ||
| unitsDelta.classList.remove('delta--active'); | ||
| }, 260); |
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.
The setTimeout for the unit delta animation is not cleared on subsequent renders. If state updates happen faster than the 260ms timeout, multiple timeouts can be queued, potentially causing the animation to behave incorrectly or be cut short. To make this more robust, you should store the timeout ID and clear it before setting a new one. This will require adding a property to the previous object in createRenderer to track the timeout ID.
unitsDelta.classList.add('delta--active');
if (previous.deltaTimeoutId) {
clearTimeout(previous.deltaTimeoutId);
}
previous.deltaTimeoutId = setTimeout(() => {
unitsDelta.classList.remove('delta--active');
previous.deltaTimeoutId = null;
}, 260);
Summary of ChangesHello @cs-util, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request delivers a foundational prototype for the 'Math Marauders' game, establishing the core gameplay loop from mathematical gate selection to combat resolution and a final score summary. It integrates essential UI elements, ensures deterministic game outcomes through seeded randomness, and includes a performance monitoring system to maintain a smooth user experience. The changes are thoroughly tested and documented, setting a solid base for future iterations. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
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.
Code Review
This pull request introduces a comprehensive prototype for the Math Marauders game, including the core game loop, UI, and extensive testing. The changes are well-structured, with a clear separation of concerns between game logic, UI rendering, and utilities. The use of property-based testing and detailed documentation is commendable. My feedback focuses on improving maintainability and robustness by addressing magic numbers, potential side effects in pure functions, and making tests less brittle. I've also noted the reduction in code coverage thresholds, which should be addressed to maintain code quality.
| branches: 80, | ||
| functions: 85, |
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.
The code coverage thresholds for branches and functions have been lowered. While this can be acceptable temporarily when introducing a large amount of new code, it's a practice that can reduce overall code quality and reliability if not managed carefully. It would be beneficial to create a follow-up ticket to track the work required to increase these thresholds back to their original values (90%) to ensure the project maintains a high standard of test coverage.
| divide: { symbol: '÷', color: 'hazard' }, | ||
| }; | ||
|
|
||
| let gateIdCounter = 0; |
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.
The gateIdCounter is a module-level variable that is mutated by the generateGateSet function. This introduces a side effect into what could otherwise be a pure function. If multiple instances of GameEngine are created, they will share this counter, which can lead to non-deterministic or unexpected gate IDs, especially if runs are intended to be reproducible from a seed. To ensure determinism and avoid shared mutable state, this counter should be managed as part of the game's state, perhaps within the GameEngine instance itself and passed into generateGateSet or managed via the seeded RNG.
| export function computeScore({ | ||
| initialUnits, | ||
| remainingUnits, | ||
| elapsedSeconds, | ||
| gatesTaken, | ||
| skirmishVolleys, | ||
| chaseOutcome, | ||
| }) { | ||
| const gateBonus = gatesTaken * 60; | ||
| const survivalBonus = Math.max(0, remainingUnits) * 15; | ||
| const speedBonus = Math.max(0, 180 - Math.round(elapsedSeconds)) * 2; | ||
| const volleyEfficiency = Math.max(0, (5 - skirmishVolleys) * 20); | ||
| const chasePenalty = chaseOutcome === 'escape' ? 0 : 120; | ||
| const attritionPenalty = Math.max(0, initialUnits - remainingUnits) * 2; | ||
|
|
||
| const total = | ||
| gateBonus + | ||
| survivalBonus + | ||
| speedBonus + | ||
| volleyEfficiency - | ||
| chasePenalty - | ||
| attritionPenalty; | ||
| return { | ||
| total, | ||
| breakdown: { | ||
| gateBonus, | ||
| survivalBonus, | ||
| speedBonus, | ||
| volleyEfficiency, | ||
| chasePenalty, | ||
| attritionPenalty, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export function computeStarRating({ score, survivalRate, elapsedSeconds }) { | ||
| const thresholds = [180, 320, 460]; | ||
| let stars = 0; | ||
| for (let i = 0; i < thresholds.length; i += 1) { | ||
| if (score >= thresholds[i]) { | ||
| stars += 1; | ||
| } | ||
| } | ||
|
|
||
| if (survivalRate < 0.5 && stars > 2) { | ||
| stars = 2; | ||
| } | ||
| if (elapsedSeconds > 210 && stars > 1) { | ||
| stars -= 1; | ||
| } | ||
|
|
||
| return { stars, thresholds }; | ||
| } |
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.
The scoring and star rating logic in this file is heavily reliant on 'magic numbers' (e.g., 60, 15, 120 in computeScore, and [180, 320, 460], 0.5, 210 in computeStarRating). Since these values are critical for game balance and player progression, they should be extracted into a configuration object or a set of named constants. This would make the game much easier to tune and the logic easier to understand.
| <style> | ||
| :root { | ||
| --bg-start: #12151a; | ||
| --bg-end: #1e2633; | ||
| --text-primary: #f5f7ff; | ||
| --text-muted: #9aa4c6; | ||
| --accent-pink: #ff5fa2; | ||
| --accent-teal: #33d6a6; | ||
| --accent-yellow: #ffd166; | ||
| --accent-blue: #00d1ff; | ||
| --danger: #ff7a59; | ||
| --card-bg: rgba(18, 21, 26, 0.78); | ||
| --panel-bg: rgba(30, 38, 51, 0.75); | ||
| --border-soft: rgba(255, 255, 255, 0.08); | ||
| --shadow-soft: 0 24px 48px rgba(12, 14, 18, 0.35); | ||
| } | ||
|
|
||
| * { | ||
| box-sizing: border-box; | ||
| } | ||
|
|
||
| body { | ||
| margin: 0; | ||
| font-family: | ||
| 'Inter', | ||
| 'Segoe UI', | ||
| system-ui, | ||
| -apple-system, | ||
| sans-serif; | ||
| color: var(--text-primary); | ||
| background: | ||
| radial-gradient( | ||
| circle at 20% 20%, | ||
| rgba(255, 95, 162, 0.18), | ||
| transparent 60% | ||
| ), | ||
| radial-gradient( | ||
| circle at 80% 0%, | ||
| rgba(51, 214, 166, 0.16), | ||
| transparent 55% | ||
| ), | ||
| linear-gradient(160deg, var(--bg-start), var(--bg-end)); | ||
| min-height: 100vh; | ||
| display: flex; | ||
| justify-content: center; | ||
| } | ||
|
|
||
| .app { | ||
| width: min(960px, 100%); | ||
| min-height: 100vh; | ||
| display: flex; | ||
| flex-direction: column; | ||
| padding: 1.2rem 1rem 1.5rem; | ||
| gap: 1rem; | ||
| position: relative; | ||
| } | ||
|
|
||
| header.hud { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.75rem; | ||
| background: var(--card-bg); | ||
| padding: 1rem; | ||
| border-radius: 18px; | ||
| border: 1px solid var(--border-soft); | ||
| box-shadow: var(--shadow-soft); | ||
| } | ||
|
|
||
| .hud__row { | ||
| display: flex; | ||
| flex-wrap: wrap; | ||
| align-items: center; | ||
| gap: 0.75rem; | ||
| } | ||
|
|
||
| .badge { | ||
| background: rgba(255, 95, 162, 0.16); | ||
| border-radius: 999px; | ||
| padding: 0.35rem 0.8rem; | ||
| font-weight: 600; | ||
| letter-spacing: 0.02em; | ||
| } | ||
|
|
||
| .hud__metrics { | ||
| display: grid; | ||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | ||
| gap: 0.75rem; | ||
| flex: 1 1 220px; | ||
| } | ||
|
|
||
| .metric { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.2rem; | ||
| padding: 0.65rem 0.75rem; | ||
| border-radius: 14px; | ||
| background: rgba(18, 21, 26, 0.6); | ||
| border: 1px solid rgba(255, 255, 255, 0.08); | ||
| } | ||
|
|
||
| .metric__label { | ||
| text-transform: uppercase; | ||
| font-size: 0.65rem; | ||
| letter-spacing: 0.12em; | ||
| color: var(--text-muted); | ||
| } | ||
|
|
||
| .metric__value { | ||
| font-size: 1.45rem; | ||
| font-variant-numeric: tabular-nums; | ||
| font-weight: 700; | ||
| } | ||
|
|
||
| .metric__delta { | ||
| font-size: 0.9rem; | ||
| font-weight: 600; | ||
| opacity: 0; | ||
| transform: translateY(6px); | ||
| transition: | ||
| opacity 160ms ease, | ||
| transform 160ms ease; | ||
| font-variant-numeric: tabular-nums; | ||
| } | ||
|
|
||
| .metric__delta.delta--active { | ||
| opacity: 1; | ||
| transform: translateY(0); | ||
| } | ||
|
|
||
| .metric__delta[data-delta-tone='positive'] { | ||
| color: var(--accent-teal); | ||
| } | ||
|
|
||
| .metric__delta[data-delta-tone='negative'] { | ||
| color: var(--danger); | ||
| } | ||
|
|
||
| .hud__status { | ||
| display: flex; | ||
| gap: 0.5rem; | ||
| align-items: center; | ||
| } | ||
|
|
||
| .status-chip { | ||
| background: rgba(255, 255, 255, 0.08); | ||
| border-radius: 999px; | ||
| padding: 0.3rem 0.75rem; | ||
| font-size: 0.8rem; | ||
| font-weight: 600; | ||
| } | ||
|
|
||
| .status-chip--accent { | ||
| background: rgba(51, 214, 166, 0.2); | ||
| color: var(--accent-teal); | ||
| } | ||
|
|
||
| .status-chip--ghost { | ||
| background: rgba(0, 209, 255, 0.16); | ||
| color: var(--accent-blue); | ||
| } | ||
|
|
||
| .status-chip--mute { | ||
| background: rgba(255, 122, 89, 0.2); | ||
| color: var(--danger); | ||
| } | ||
|
|
||
| .control { | ||
| background: rgba(18, 21, 26, 0.75); | ||
| border: 1px solid rgba(255, 255, 255, 0.15); | ||
| border-radius: 999px; | ||
| padding: 0.55rem 1.1rem; | ||
| color: var(--text-primary); | ||
| font-weight: 600; | ||
| cursor: pointer; | ||
| transition: transform 120ms ease; | ||
| } | ||
|
|
||
| .control:hover { | ||
| transform: translateY(-1px); | ||
| } | ||
|
|
||
| .control--active { | ||
| background: rgba(255, 95, 162, 0.25); | ||
| color: var(--accent-pink); | ||
| } | ||
|
|
||
| .meta-row { | ||
| display: flex; | ||
| flex-wrap: wrap; | ||
| gap: 1rem; | ||
| font-size: 0.85rem; | ||
| color: var(--text-muted); | ||
| } | ||
|
|
||
| main.board { | ||
| flex: 1 1 auto; | ||
| display: grid; | ||
| gap: 1rem; | ||
| grid-template-columns: minmax(0, 1fr); | ||
| position: relative; | ||
| } | ||
|
|
||
| .forward-panel { | ||
| background: var(--panel-bg); | ||
| border-radius: 20px; | ||
| border: 1px solid var(--border-soft); | ||
| padding: 1.2rem; | ||
| box-shadow: var(--shadow-soft); | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.9rem; | ||
| } | ||
|
|
||
| .forward-panel h1 { | ||
| margin: 0; | ||
| font-size: clamp(1.6rem, 4vw, 2.2rem); | ||
| letter-spacing: 0.04em; | ||
| } | ||
|
|
||
| .forward-panel__hint { | ||
| margin: 0; | ||
| color: var(--text-muted); | ||
| } | ||
|
|
||
| .gate-grid { | ||
| display: grid; | ||
| gap: 0.8rem; | ||
| } | ||
|
|
||
| .gate-card { | ||
| text-align: left; | ||
| padding: 1rem; | ||
| border-radius: 18px; | ||
| border: 1px solid rgba(255, 255, 255, 0.12); | ||
| font-size: 1rem; | ||
| font-weight: 600; | ||
| color: var(--text-primary); | ||
| cursor: pointer; | ||
| transition: | ||
| transform 140ms ease, | ||
| box-shadow 140ms ease; | ||
| } | ||
|
|
||
| .gate-card:hover { | ||
| transform: translateY(-2px); | ||
| box-shadow: 0 16px 32px rgba(0, 0, 0, 0.25); | ||
| } | ||
|
|
||
| .gate-card__label { | ||
| display: block; | ||
| font-size: 1.35rem; | ||
| margin-bottom: 0.35rem; | ||
| } | ||
|
|
||
| .gate-card__description { | ||
| font-size: 0.85rem; | ||
| color: rgba(255, 255, 255, 0.74); | ||
| } | ||
|
|
||
| .gate-card--positive { | ||
| background: rgba(51, 214, 166, 0.18); | ||
| } | ||
|
|
||
| .gate-card--negative { | ||
| background: rgba(255, 122, 89, 0.22); | ||
| } | ||
|
|
||
| .gate-card--boost { | ||
| background: rgba(255, 209, 102, 0.2); | ||
| color: #2a2430; | ||
| } | ||
|
|
||
| .gate-card--hazard { | ||
| background: rgba(0, 209, 255, 0.18); | ||
| } | ||
|
|
||
| .log-panel { | ||
| background: rgba(18, 21, 26, 0.72); | ||
| border-radius: 20px; | ||
| border: 1px solid var(--border-soft); | ||
| padding: 1rem 1.2rem; | ||
| box-shadow: var(--shadow-soft); | ||
| max-height: 260px; | ||
| overflow-y: auto; | ||
| } | ||
|
|
||
| .log-panel h2 { | ||
| margin: 0 0 0.6rem; | ||
| font-size: 1rem; | ||
| letter-spacing: 0.06em; | ||
| text-transform: uppercase; | ||
| color: var(--text-muted); | ||
| } | ||
|
|
||
| .log-panel ol { | ||
| list-style: none; | ||
| margin: 0; | ||
| padding: 0; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.4rem; | ||
| font-size: 0.9rem; | ||
| } | ||
|
|
||
| .end-card { | ||
| position: absolute; | ||
| left: 50%; | ||
| top: 50%; | ||
| transform: translate(-50%, -50%); | ||
| background: rgba(18, 21, 26, 0.95); | ||
| border-radius: 24px; | ||
| padding: 1.5rem 1.8rem; | ||
| border: 1px solid rgba(255, 255, 255, 0.2); | ||
| text-align: center; | ||
| width: min(320px, 90%); | ||
| box-shadow: 0 30px 60px rgba(0, 0, 0, 0.5); | ||
| z-index: 5; | ||
| } | ||
|
|
||
| .end-card__stars { | ||
| display: flex; | ||
| justify-content: center; | ||
| gap: 0.4rem; | ||
| font-size: 1.8rem; | ||
| margin-bottom: 0.5rem; | ||
| } | ||
|
|
||
| .star { | ||
| color: rgba(255, 255, 255, 0.3); | ||
| } | ||
|
|
||
| .star--filled { | ||
| color: var(--accent-yellow); | ||
| } | ||
|
|
||
| .end-card__summary { | ||
| margin: 0; | ||
| color: var(--text-muted); | ||
| } | ||
|
|
||
| footer.controls-bar { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| gap: 1rem; | ||
| background: var(--card-bg); | ||
| padding: 0.9rem 1.2rem; | ||
| border-radius: 18px; | ||
| border: 1px solid var(--border-soft); | ||
| box-shadow: var(--shadow-soft); | ||
| } | ||
|
|
||
| .slider { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.35rem; | ||
| font-size: 0.85rem; | ||
| color: var(--text-muted); | ||
| width: 160px; | ||
| } | ||
|
|
||
| .slider input[type='range'] { | ||
| width: 100%; | ||
| } | ||
|
|
||
| .cta { | ||
| flex: 1; | ||
| border: none; | ||
| background: linear-gradient( | ||
| 135deg, | ||
| var(--accent-pink), | ||
| var(--accent-teal) | ||
| ); | ||
| color: #0d1016; | ||
| font-weight: 700; | ||
| padding: 0.9rem 1.2rem; | ||
| border-radius: 999px; | ||
| font-size: 1.1rem; | ||
| cursor: pointer; | ||
| box-shadow: 0 18px 36px rgba(255, 95, 162, 0.35); | ||
| transition: | ||
| transform 140ms ease, | ||
| box-shadow 140ms ease; | ||
| } | ||
|
|
||
| .cta:hover { | ||
| transform: translateY(-2px); | ||
| box-shadow: 0 22px 40px rgba(255, 95, 162, 0.4); | ||
| } | ||
|
|
||
| .pause-menu { | ||
| position: absolute; | ||
| top: 100px; | ||
| right: 1.5rem; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| padding: 1rem; | ||
| border-radius: 16px; | ||
| background: rgba(18, 21, 26, 0.92); | ||
| border: 1px solid rgba(255, 255, 255, 0.12); | ||
| box-shadow: var(--shadow-soft); | ||
| z-index: 10; | ||
| } | ||
|
|
||
| .pause-menu button { | ||
| background: rgba(255, 255, 255, 0.08); | ||
| border: none; | ||
| color: var(--text-primary); | ||
| padding: 0.6rem 0.9rem; | ||
| border-radius: 12px; | ||
| font-weight: 600; | ||
| cursor: pointer; | ||
| } | ||
|
|
||
| @media (min-width: 720px) { | ||
| main.board { | ||
| grid-template-columns: 2fr 1fr; | ||
| } | ||
|
|
||
| .gate-grid { | ||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | ||
| } | ||
|
|
||
| .forward-panel { | ||
| min-height: 360px; | ||
| } | ||
| } | ||
| </style> |
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.
|
|
||
| runSkirmishPhase() { | ||
| const enemyCount = Math.max( | ||
| 6, |
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.
| while (players > 0 && progress < BASE_DISTANCE && timeElapsed < 80) { | ||
| timeElapsed += STEP_DURATION; | ||
| progress += PLAYER_SPEED * STEP_DURATION; | ||
| chaserProgress += CHASER_SPEED * STEP_DURATION; | ||
|
|
||
| const volleyLoss = Math.max(1, Math.round(players * (0.08 + rng() * 0.04))); | ||
| players = Math.max(0, players - volleyLoss); | ||
| events.push({ type: 'volley', volleyLoss, playersRemaining: players }); | ||
|
|
||
| if (progress >= nextGate && gatesCleared < gateCount) { | ||
| gatesCleared += 1; | ||
| const surgeLoss = Math.round(players * 0.05); | ||
| players = Math.max(0, players - surgeLoss); | ||
| chaserProgress += 8; | ||
| nextGate += gateInterval; | ||
| events.push({ | ||
| type: 'surge', | ||
| surgeLoss, | ||
| playersRemaining: players, | ||
| gatesCleared, | ||
| }); | ||
| } |
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.
This function contains several 'magic numbers' that control the balance and behavior of the reverse chase phase. For example:
80: Max time elapsed-12: Initial chaser progress8: Chaser surge distance2: Catch distance threshold0.08,0.04,0.05: Loss multipliers
To improve maintainability and make game balancing easier, it's recommended to extract these values into a configuration object or named constants at the top of the file.
| const intensity = Math.min(attackers, defenders) ** 0.85; | ||
| const jitter = 0.85 + rng() * 0.3; | ||
| const damage = baseDamage * intensity * jitter; |
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.
The damage calculation uses magic numbers 0.85 and 0.3 for intensity and jitter. While 0.85 is mentioned in the spec, extracting these into named constants (e.g., DAMAGE_INTENSITY_EXPONENT, JITTER_RANGE, JITTER_BASE) at the top of the file would improve readability and make it easier to tweak the combat feel.
| engine.getState().phase = 'end'; | ||
| startButton.click(); | ||
| expect(mockRestart).toHaveBeenCalledTimes(1); | ||
| engine.getState().phase = 'forward'; | ||
| startButton.click(); |
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.
These tests directly manipulate the engine's state (e.g., engine.getState().phase = 'end') to set up test conditions. This makes the tests brittle, as they depend on the internal state structure of the GameEngine. It would be more robust to drive the engine to the desired state using its public API, which would decouple the tests from implementation details.
| pauseButton.addEventListener('click', () => { | ||
| const isOpen = pauseButton.getAttribute('aria-expanded') === 'true'; | ||
| const nextOpen = !isOpen; | ||
| pauseButton.setAttribute('aria-expanded', String(nextOpen)); | ||
| container.hidden = !nextOpen; | ||
| if (nextOpen) { | ||
| const state = engine.getState(); | ||
| if (!state.isPaused) { | ||
| engine.togglePause(); | ||
| } | ||
| } else if (engine.getState().isPaused) { | ||
| engine.togglePause(); | ||
| } | ||
| }); |
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.
The logic for managing the pause menu's visibility and the game's pause state is a bit complex, with state being read from the DOM (aria-expanded). This can be fragile. A cleaner approach would be to treat the GameEngine as the single source of truth. The UI event handlers should only dispatch actions to the engine (e.g., engine.togglePause()), and the renderer should be solely responsible for updating the UI (including aria-expanded and hidden attributes) based on the state it receives from the engine. This simplifies the event handlers and centralizes state management.
| if (previousUnits !== null && previousUnits !== currentUnits) { | ||
| const delta = currentUnits - previousUnits; | ||
| const sign = delta > 0 ? '+' : ''; | ||
| unitsDelta.textContent = `${sign}${delta}`; | ||
| unitsDelta.dataset.deltaTone = delta >= 0 ? 'positive' : 'negative'; | ||
| unitsDelta.classList.add('delta--active'); | ||
| setTimeout(() => { | ||
| unitsDelta.classList.remove('delta--active'); | ||
| }, 260); | ||
| } | ||
| previous.units = currentUnits; | ||
| previous.score = displayScore; | ||
| } | ||
|
|
||
| function renderPhase(state, elements) { | ||
| const { phaseValue, waveValue, muteBadge } = elements.hud; | ||
| const phaseLabel = mapPhaseToLabel(state.phase, state.isPaused); | ||
| phaseValue.textContent = phaseLabel; | ||
| waveValue.textContent = `Wave ${state.wave}`; | ||
| muteBadge.hidden = !state.muted; | ||
| } | ||
|
|
||
| function mapPhaseToLabel(phase, isPaused) { | ||
| if (isPaused) { | ||
| return 'Paused'; | ||
| } | ||
| switch (phase) { | ||
| case 'idle': | ||
| return 'Ready'; | ||
| case 'forward': | ||
| return 'Forward Run'; | ||
| case 'skirmish': | ||
| return 'Skirmish'; | ||
| case 'reverse': | ||
| return 'Reverse Chase'; | ||
| case 'end': | ||
| return 'End Card'; | ||
| default: | ||
| return phase; | ||
| } | ||
| } | ||
|
|
||
| function renderGateOptions(state, elements) { | ||
| const { gateContainer, forwardHint } = elements.forward; | ||
| gateContainer.innerHTML = ''; | ||
|
|
||
| if (state.phase !== 'forward') { | ||
| forwardHint.textContent = | ||
| state.forward.decisionIndex >= state.forward.totalDecisions | ||
| ? 'All gates cleared.' | ||
| : 'Awaiting next run.'; | ||
| return; | ||
| } | ||
|
|
||
| forwardHint.textContent = `Pick gate ${state.forward.decisionIndex + 1} of ${state.forward.totalDecisions}`; | ||
| const currentSet = state.forward.current; | ||
| if (!currentSet) { | ||
| return; | ||
| } | ||
|
|
||
| currentSet.options.forEach((option) => { | ||
| const card = document.createElement('button'); | ||
| card.type = 'button'; | ||
| card.className = `gate-card gate-card--${option.dominantTone}`; | ||
| card.dataset.optionId = option.id; | ||
| card.innerHTML = ` | ||
| <span class="gate-card__label">${option.label}</span> | ||
| <span class="gate-card__description">${option.description}</span> | ||
| `; | ||
| gateContainer.appendChild(card); | ||
| }); | ||
| } | ||
|
|
||
| function renderLogs(state, elements) { | ||
| const { logList } = elements; | ||
| logList.innerHTML = ''; | ||
| const recent = state.logs.slice(-6).reverse(); | ||
| recent.forEach((entry) => { | ||
| const item = document.createElement('li'); | ||
| item.textContent = entry; | ||
| logList.appendChild(item); | ||
| }); | ||
| } | ||
|
|
||
| function renderEndCard(state, elements) { | ||
| const { endCard, starList, summary, restartCta } = elements.end; | ||
| if (state.phase !== 'end' || !state.score) { | ||
| endCard.hidden = true; | ||
| restartCta.textContent = 'Start Run'; | ||
| return; | ||
| } | ||
|
|
||
| endCard.hidden = false; | ||
| summary.textContent = `Score ${formatCompactNumber(state.score.total)} | Units ${state.units.current}`; | ||
| restartCta.textContent = 'Restart'; | ||
| starList.innerHTML = ''; | ||
| for (let i = 1; i <= 3; i += 1) { | ||
| const star = document.createElement('span'); | ||
| star.className = `star ${i <= state.stars.stars ? 'star--filled' : ''}`; | ||
| star.textContent = '★'; | ||
| starList.appendChild(star); | ||
| } | ||
| } |
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.
This file contains several 'magic numbers' that should be extracted into named constants for better readability and maintainability:
- Line 45: The timeout duration
260for the delta animation. - Line 115: The number of recent log entries to display,
6. - Line 135: The total number of stars to render,
3.
Defining these as constants (e.g., DELTA_ANIMATION_DURATION, MAX_LOG_ENTRIES, MAX_STARS) at the top of the file would make the code clearer and easier to modify.
Summary
Testing
https://chatgpt.com/codex/tasks/task_e_68ed71ef0c6c832c92b87b4cb54d06e1