diff --git a/.github/workflows/security-intent-review-gate.yml b/.github/workflows/security-intent-review-gate.yml index c526844..9fd0206 100644 --- a/.github/workflows/security-intent-review-gate.yml +++ b/.github/workflows/security-intent-review-gate.yml @@ -22,28 +22,16 @@ concurrency: cancel-in-progress: true jobs: - # ───────────────────────────────────────────────────────────────────── - # Job 1: pre-checks - # Owns: input validation, checkout, CI wait, mergeability, review range - # ───────────────────────────────────────────────────────────────────── - pre-checks: + security-intent-review: runs-on: ubuntu-latest - timeout-minutes: 15 - outputs: - passed: ${{ steps.result.outputs.passed }} - pr_number: ${{ steps.checkout_review.outputs.pr_number }} - head_sha: ${{ steps.checkout_review.outputs.head_sha }} - base_sha: ${{ steps.refs.outputs.base_sha }} - changed_count: ${{ steps.refs.outputs.changed_count }} - review_branch: ${{ steps.checkout_review.outputs.review_branch }} - fork_owner: ${{ steps.checkout_review.outputs.fork_owner }} - fork_repo: ${{ steps.checkout_review.outputs.fork_repo }} + timeout-minutes: 45 env: BASE_BRANCH: main REVIEW_BRANCH: ${{ inputs.branch }} TASK_ID: ${{ inputs.task_id }} SECURITY_INTENT_REVIEW_BASE_URL: ${{ vars.SECURITY_INTENT_REVIEW_BASE_URL }} SECURITY_INTENT_REVIEW_API_KEY: ${{ secrets.INTERNAL_API_KEY }} + SECURITY_REVIEW_FAIL_ON: needs_review steps: - name: Validate dispatch inputs shell: bash @@ -106,9 +94,6 @@ jobs: PR_NUM=$(echo "$PR_JSON" | jq -r '.number') echo "pr_number=${PR_NUM}" >> "$GITHUB_OUTPUT" - echo "fork_owner=${OWNER}" >> "$GITHUB_OUTPUT" - echo "fork_repo=${REPO}" >> "$GITHUB_OUTPUT" - echo "review_branch=${BRANCH}" >> "$GITHUB_OUTPUT" echo "Found PR #${PR_NUM}, branch ${BRANCH} in ${OWNER}/${REPO}" git remote add fork "https://github.com/${OWNER}/${REPO}.git" 2>/dev/null || true git fetch --no-tags fork "refs/heads/${BRANCH}" @@ -136,7 +121,6 @@ jobs: allowed-conclusions: success - name: Report CI failure - id: ci_failure if: ${{ always() && steps.wait_ci.outcome == 'failure' }} shell: bash env: @@ -147,15 +131,12 @@ jobs: set -euo pipefail mkdir -p .contextgen/security_review - # Fetch CI failure logs. For fork PRs, --commit won't match (GitHub uses the - # merge commit SHA, not the PR head SHA), so we search by headSha in the JSON - # output instead. We also avoid hardcoding the workflow filename. + # Fetch CI failure logs (try run logs first, fall back to check output) CI_LOGS="CI build failed (no details available)" if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then - RUN_ID=$(gh run list --repo "${{ github.repository }}" \ - --json databaseId,conclusion,headSha \ - --jq "[.[] | select(.headSha == \"${HEAD_SHA}\" and .conclusion == \"failure\")][0].databaseId" \ - 2>/dev/null || true) + RUN_ID=$(gh run list --repo "${{ github.repository }}" --commit "$HEAD_SHA" \ + --workflow ci.yml --json databaseId,conclusion \ + --jq '[.[] | select(.conclusion == "failure")][0].databaseId' 2>/dev/null || true) if [ -n "$RUN_ID" ] && [ "$RUN_ID" != "null" ]; then CI_LOGS=$(gh run view "$RUN_ID" --repo "${{ github.repository }}" --log-failed 2>/dev/null | tail -100 || echo "$CI_LOGS") fi @@ -183,68 +164,10 @@ jobs: ] }' > .contextgen/security_review/security_review.json echo "::error::CI build failed — skipping security review" - - - name: Upload CI failure result - if: ${{ always() && steps.ci_failure.outcome == 'success' }} - uses: actions/upload-artifact@v4 - with: - name: review-result - path: .contextgen/security_review/security_review.json - - - name: Check PR mergeability - id: check_mergeable - if: ${{ !cancelled() && steps.wait_ci.outcome == 'success' }} - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ steps.checkout_review.outputs.pr_number }} - run: | - set -euo pipefail - if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" = "null" ]; then - echo "mergeable=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - MERGEABLE="UNKNOWN" - for i in 1 2 3 4 5; do - MERGEABLE=$(gh pr view "$PR_NUMBER" --json mergeable --jq '.mergeable' 2>/dev/null || echo "UNKNOWN") - if [ "$MERGEABLE" != "UNKNOWN" ]; then break; fi - sleep 5 - done - echo "PR #${PR_NUMBER} mergeable: ${MERGEABLE}" - if [ "$MERGEABLE" = "CONFLICTING" ]; then - echo "mergeable=false" >> "$GITHUB_OUTPUT" - else - echo "mergeable=true" >> "$GITHUB_OUTPUT" - fi - - - name: Report merge conflict - id: merge_conflict - if: ${{ !cancelled() && steps.check_mergeable.outputs.mergeable == 'false' }} - shell: bash - run: | - set -euo pipefail - mkdir -p .contextgen/security_review - jq -n \ - --arg task_id "$TASK_ID" \ - --arg branch "$REVIEW_BRANCH" \ - --arg base_branch "$BASE_BRANCH" \ - '{ - task_id: $task_id, branch: $branch, base_branch: $base_branch, - base_sha: "", head_sha: "", - final_decision: "merge_conflict", average_approval_score: 0, - recommended_actions: ["MERGE_CONFLICT: The target branch has changed since this PR was created. Rebase onto the latest upstream default branch, resolve conflicts, and resubmit."] - }' > .contextgen/security_review/security_review.json - - - name: Upload merge conflict result - if: ${{ always() && steps.merge_conflict.outcome == 'success' }} - uses: actions/upload-artifact@v4 - with: - name: review-result - path: .contextgen/security_review/security_review.json + exit 1 - name: Resolve review range id: refs - if: ${{ !cancelled() && steps.wait_ci.outcome == 'success' && steps.check_mergeable.outputs.mergeable != 'false' }} shell: bash run: | set -euo pipefail @@ -258,59 +181,6 @@ jobs: echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT" echo "changed_count=${CHANGED_COUNT}" >> "$GITHUB_OUTPUT" - - name: Set result - id: result - if: always() - shell: bash - run: | - if [ "${{ steps.wait_ci.outcome }}" = "success" ] && [ "${{ steps.check_mergeable.outputs.mergeable }}" != "false" ]; then - echo "passed=true" >> "$GITHUB_OUTPUT" - else - echo "passed=false" >> "$GITHUB_OUTPUT" - fi - - # ───────────────────────────────────────────────────────────────────── - # Job 2: security-review - # Runs the actual heuristic + Claude + Codex review pipeline - # Only runs when pre-checks passed - # ───────────────────────────────────────────────────────────────────── - security-review: - needs: pre-checks - if: needs.pre-checks.outputs.passed == 'true' - runs-on: ubuntu-latest - timeout-minutes: 45 - env: - BASE_BRANCH: main - REVIEW_BRANCH: ${{ needs.pre-checks.outputs.review_branch || inputs.branch }} - TASK_ID: ${{ inputs.task_id }} - SECURITY_INTENT_REVIEW_BASE_URL: ${{ vars.SECURITY_INTENT_REVIEW_BASE_URL }} - SECURITY_INTENT_REVIEW_API_KEY: ${{ secrets.INTERNAL_API_KEY }} - SECURITY_REVIEW_FAIL_ON: needs_review - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 0 - persist-credentials: false - clean: true - - - name: Checkout review branch - shell: bash - run: | - set -euo pipefail - FORK_OWNER="${{ needs.pre-checks.outputs.fork_owner }}" - FORK_REPO="${{ needs.pre-checks.outputs.fork_repo }}" - BRANCH="${{ needs.pre-checks.outputs.review_branch }}" - git remote add fork "https://github.com/${FORK_OWNER}/${FORK_REPO}.git" 2>/dev/null || true - git fetch --no-tags fork "refs/heads/${BRANCH}" - git checkout -B "$BRANCH" FETCH_HEAD - - - name: Fetch base branch - shell: bash - run: | - set -euo pipefail - git fetch --no-tags origin "refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}" - - name: Preflight task context fetch shell: bash run: | @@ -347,8 +217,8 @@ jobs: set -euo pipefail mkdir -p .contextgen/security_review - BASE_SHA="${{ needs.pre-checks.outputs.base_sha }}" - HEAD_SHA="${{ needs.pre-checks.outputs.head_sha }}" + BASE_SHA="${{ steps.refs.outputs.base_sha }}" + HEAD_SHA="${{ steps.refs.outputs.head_sha }}" DIFF_FILE="$(mktemp)" git diff --no-color -U0 "$BASE_SHA" "$HEAD_SHA" > "$DIFF_FILE" 2>/dev/null || true @@ -393,7 +263,7 @@ jobs: *) echo 0 ;; esac )" \ - --argjson changed_count "${{ needs.pre-checks.outputs.changed_count }}" \ + --argjson changed_count "${{ steps.refs.outputs.changed_count }}" \ '{decision: $decision, reason: $reason, signals: $signals, approval_score: $approval_score, changed_files_count: $changed_count}' \ > .contextgen/security_review/heuristic_results.json @@ -552,11 +422,11 @@ jobs: - A preflight copy is available at .contextgen/security_review/task_context.txt if you need to confirm what the workflow received. STEP 3 - Understand the branch changes: - - Run: git diff --name-status ${{ needs.pre-checks.outputs.base_sha }} ${{ needs.pre-checks.outputs.head_sha }} - - Run: git diff ${{ needs.pre-checks.outputs.base_sha }} ${{ needs.pre-checks.outputs.head_sha }} + - Run: git diff --name-status ${{ steps.refs.outputs.base_sha }} ${{ steps.refs.outputs.head_sha }} + - Run: git diff ${{ steps.refs.outputs.base_sha }} ${{ steps.refs.outputs.head_sha }} - Use Read to view the COMPLETE content of every changed file - do not skip or truncate - Use Grep to search for suspicious patterns across the codebase if needed - - If the diff is large, also run: git log --oneline ${{ needs.pre-checks.outputs.base_sha }}..${{ needs.pre-checks.outputs.head_sha }} + - If the diff is large, also run: git log --oneline ${{ steps.refs.outputs.base_sha }}..${{ steps.refs.outputs.head_sha }} - After reading the diff, inspect the project-specific sensitive surfaces you identified in STEP 1, even if some of those files are unchanged. - Prioritize surfaces that could hide scope creep or harm if altered indirectly by the changed code. @@ -734,8 +604,8 @@ jobs: - A preflight copy is available at .contextgen/security_review/task_context.txt if you need to confirm what the workflow received. STEP 3 - Examine the branch changes in full: - - Run: git diff --name-status ${{ needs.pre-checks.outputs.base_sha }} ${{ needs.pre-checks.outputs.head_sha }} - - Run: git diff ${{ needs.pre-checks.outputs.base_sha }} ${{ needs.pre-checks.outputs.head_sha }} + - Run: git diff --name-status ${{ steps.refs.outputs.base_sha }} ${{ steps.refs.outputs.head_sha }} + - Run: git diff ${{ steps.refs.outputs.base_sha }} ${{ steps.refs.outputs.head_sha }} - For EVERY changed file, read the COMPLETE file content with: cat Do NOT use head or tail - you must see the full file to detect hidden payloads. - CRITICAL: For each changed file, trace files that interact with it: @@ -744,7 +614,7 @@ jobs: may look safe in isolation but become dangerous when you see how callers consume it - Also inspect configuration files (package.json, CI workflows, Dockerfiles, etc.) that could be affected by the changes, even if they were not directly modified - - If the diff is large, also run: git log --oneline ${{ needs.pre-checks.outputs.base_sha }}..${{ needs.pre-checks.outputs.head_sha }} + - If the diff is large, also run: git log --oneline ${{ steps.refs.outputs.base_sha }}..${{ steps.refs.outputs.head_sha }} - Inspect the project-specific sensitive surfaces you identified in STEP 1, even if some of those files are unchanged. - Prioritize surfaces that could hide scope creep or harm if altered indirectly by the changed code. @@ -832,8 +702,8 @@ jobs: if: always() shell: bash env: - BASE_SHA: ${{ needs.pre-checks.outputs.base_sha }} - HEAD_SHA: ${{ needs.pre-checks.outputs.head_sha }} + BASE_SHA: ${{ steps.refs.outputs.base_sha }} + HEAD_SHA: ${{ steps.refs.outputs.head_sha }} run: | set -euo pipefail OUT_DIR=".contextgen/security_review" @@ -895,41 +765,13 @@ jobs: echo "- Base branch: \`${BASE_BRANCH}\`" echo "- Final decision: \`${FINAL_DECISION}\`" echo "- Average approval score: \`${AVERAGE_APPROVAL_SCORE}\`" - echo "- Changed files: \`${{ needs.pre-checks.outputs.changed_count }}\`" + echo "- Changed files: \`${{ steps.refs.outputs.changed_count }}\`" echo echo "### Heuristic summary" cat "${OUT_DIR}/heuristic_summary.txt" } >> "$GITHUB_STEP_SUMMARY" - - name: Upload review result - if: always() - uses: actions/upload-artifact@v4 - with: - name: review-result - path: .contextgen/security_review/security_review.json - - # ───────────────────────────────────────────────────────────────────── - # Job 3: report - # Always runs. Downloads the artifact and posts results. - # ───────────────────────────────────────────────────────────────────── - report: - needs: [pre-checks, security-review] - if: always() - runs-on: ubuntu-latest - env: - REVIEW_BRANCH: ${{ needs.pre-checks.outputs.review_branch || inputs.branch }} - TASK_ID: ${{ inputs.task_id }} - BASE_BRANCH: main - SECURITY_INTENT_REVIEW_BASE_URL: ${{ vars.SECURITY_INTENT_REVIEW_BASE_URL }} - SECURITY_INTENT_REVIEW_API_KEY: ${{ secrets.INTERNAL_API_KEY }} - steps: - - name: Download review result - uses: actions/download-artifact@v4 - with: - name: review-result - path: .contextgen/security_review - - - name: Log review result + - name: Log combined review result if: always() shell: bash run: | @@ -952,7 +794,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} GITHUB_API_URL: ${{ github.api_url }} GITHUB_REPOSITORY: ${{ github.repository }} - PR_NUMBER: ${{ needs.pre-checks.outputs.pr_number }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} run: | set -euo pipefail OUT_DIR=".contextgen/security_review" @@ -963,91 +805,79 @@ jobs: exit 0 fi - if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" = "null" ]; then - echo "::notice::No PR number available. Skipping PR comment." + HEAD_QUERY="$(jq -nr --arg head "${GITHUB_REPOSITORY_OWNER}:${REVIEW_BRANCH}" '$head|@uri')" + PRS_JSON="$( + curl \ + --fail \ + --silent \ + --show-error \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls?state=open&head=${HEAD_QUERY}&per_page=1" + )" + + PR_NUMBER="$(printf '%s' "$PRS_JSON" | jq -r '.[0].number // empty')" + if [ -z "$PR_NUMBER" ]; then + echo "::notice::No open PR found for branch ${REVIEW_BRANCH}. Skipping PR comment." exit 0 fi FINAL_DECISION="$(jq -r '.final_decision // "block"' "$REPORT_PATH")" AVERAGE_APPROVAL_SCORE="$(jq -r '.average_approval_score // "0"' "$REPORT_PATH")" - - # Detect whether this is a full review result (has heuristic/claude/codex) - # or a pre-check failure result (minimal JSON with just decision + actions) - HAS_FULL_REVIEW="$(jq -e '.heuristic and .claude and .codex' "$REPORT_PATH" >/dev/null 2>&1&& echo "true" || echo "false")" + HEURISTIC_APPROVAL_SCORE="$(jq -r '.heuristic.approval_score // "0"' "$REPORT_PATH")" + CLAUDE_APPROVAL_SCORE="$(jq -r '.claude.approval_score // "0"' "$REPORT_PATH")" + CODEX_APPROVAL_SCORE="$(jq -r '.codex.approval_score // "0"' "$REPORT_PATH")" + HEURISTIC_DECISION="$(jq -r '.heuristic.decision // "unknown"' "$REPORT_PATH")" + HEURISTIC_REASON="$(jq -r '.heuristic.reason // "unknown"' "$REPORT_PATH")" + HEURISTIC_SIGNALS="$(jq -r '.heuristic.signals // empty' "$REPORT_PATH")" + CLAUDE_SUMMARY="$(jq -r '.claude.summary // "No Claude summary."' "$REPORT_PATH")" + CODEX_SUMMARY="$(jq -r '.codex.summary // "No Codex summary."' "$REPORT_PATH")" COMMENT_PATH="${OUT_DIR}/pr_comment.md" - - if [ "$HAS_FULL_REVIEW" = "true" ]; then - # Full review result — render complete breakdown - HEURISTIC_APPROVAL_SCORE="$(jq -r '.heuristic.approval_score // "0"' "$REPORT_PATH")" - CLAUDE_APPROVAL_SCORE="$(jq -r '.claude.approval_score // "0"' "$REPORT_PATH")" - CODEX_APPROVAL_SCORE="$(jq -r '.codex.approval_score // "0"' "$REPORT_PATH")" - HEURISTIC_DECISION="$(jq -r '.heuristic.decision // "unknown"' "$REPORT_PATH")" - HEURISTIC_REASON="$(jq -r '.heuristic.reason // "unknown"' "$REPORT_PATH")" - HEURISTIC_SIGNALS="$(jq -r '.heuristic.signals // empty' "$REPORT_PATH")" - CLAUDE_SUMMARY="$(jq -r '.claude.summary // "No Claude summary."' "$REPORT_PATH")" - CODEX_SUMMARY="$(jq -r '.codex.summary // "No Codex summary."' "$REPORT_PATH")" - - { - echo "## Security Intent Review" - echo - echo "- Final decision: \`${FINAL_DECISION}\`" - echo "- Average approval score: \`${AVERAGE_APPROVAL_SCORE}\`" - echo "- Task ID: \`${TASK_ID}\`" - echo "- Base branch: \`${BASE_BRANCH}\`" - echo "- Reviewed branch: \`${REVIEW_BRANCH}\`" - echo - echo "### Heuristic" - echo "- Approval score: \`${HEURISTIC_APPROVAL_SCORE}\`" - echo "- Decision: \`${HEURISTIC_DECISION}\`" - echo "- Reason: \`${HEURISTIC_REASON}\`" - if [ -n "$HEURISTIC_SIGNALS" ]; then - echo "- Signals: \`${HEURISTIC_SIGNALS}\`" - fi - echo - echo "### Claude" - echo "- Approval score: \`${CLAUDE_APPROVAL_SCORE}\`" - printf '%s\n' "$CLAUDE_SUMMARY" + { + echo "## Security Intent Review" + echo + echo "- Final decision: \`${FINAL_DECISION}\`" + echo "- Average approval score: \`${AVERAGE_APPROVAL_SCORE}\`" + echo "- Task ID: \`${TASK_ID}\`" + echo "- Base branch: \`${BASE_BRANCH}\`" + echo "- Reviewed branch: \`${REVIEW_BRANCH}\`" + echo + echo "### Heuristic" + echo "- Approval score: \`${HEURISTIC_APPROVAL_SCORE}\`" + echo "- Decision: \`${HEURISTIC_DECISION}\`" + echo "- Reason: \`${HEURISTIC_REASON}\`" + if [ -n "$HEURISTIC_SIGNALS" ]; then + echo "- Signals: \`${HEURISTIC_SIGNALS}\`" + fi + echo + echo "### Claude" + echo "- Approval score: \`${CLAUDE_APPROVAL_SCORE}\`" + printf '%s\n' "$CLAUDE_SUMMARY" + echo + echo "### Codex" + echo "- Approval score: \`${CODEX_APPROVAL_SCORE}\`" + printf '%s\n' "$CODEX_SUMMARY" + if jq -e '.recommended_actions // [] | length > 0' "$REPORT_PATH" >/dev/null; then echo - echo "### Codex" - echo "- Approval score: \`${CODEX_APPROVAL_SCORE}\`" - printf '%s\n' "$CODEX_SUMMARY" - if jq -e '.recommended_actions // [] | length > 0' "$REPORT_PATH" >/dev/null; then - echo - echo "### Recommended actions" - jq -r '.recommended_actions[] | "- " + .' "$REPORT_PATH" - fi - } > "$COMMENT_PATH" - - if jq -e '[.claude.findings // [], .codex.findings // []] | add | length > 0' "$REPORT_PATH" >/dev/null; then - { - echo - echo "### Findings" - jq -r ' - [(.claude.findings // []), (.codex.findings // [])] - | add - | unique_by((.id // "") + "|" + (.file // "") + "|" + (.rationale // "")) - | .[:8] - | .[] - | "- [" + (.severity // "unknown") + "] `" + (.file // "(unknown)") + "`: " + (.rationale // .evidence // "No rationale provided") - ' "$REPORT_PATH" - } >> "$COMMENT_PATH" + echo "### Recommended actions" + jq -r '.recommended_actions[] | "- " + .' "$REPORT_PATH" fi - else - # Pre-check failure result (CI failure or merge conflict) — render minimal comment + } > "$COMMENT_PATH" + + if jq -e '[.claude.findings // [], .codex.findings // []] | add | length > 0' "$REPORT_PATH" >/dev/null; then { - echo "## Security Intent Review" - echo - echo "- Final decision: \`${FINAL_DECISION}\`" - echo "- Task ID: \`${TASK_ID}\`" - echo "- Base branch: \`${BASE_BRANCH}\`" - echo "- Reviewed branch: \`${REVIEW_BRANCH}\`" echo - if jq -e '.recommended_actions // [] | length > 0' "$REPORT_PATH" >/dev/null; then - echo "### Issues" - jq -r '.recommended_actions[] | "- " + .' "$REPORT_PATH" - fi - } > "$COMMENT_PATH" + echo "### Findings" + jq -r ' + [(.claude.findings // []), (.codex.findings // [])] + | add + | unique_by((.id // "") + "|" + (.file // "") + "|" + (.rationale // "")) + | .[:8] + | .[] + | "- [" + (.severity // "unknown") + "] `" + (.file // "(unknown)") + "`: " + (.rationale // .evidence // "No rationale provided") + ' "$REPORT_PATH" + } >> "$COMMENT_PATH" fi jq -n --rawfile body "$COMMENT_PATH" '{body: $body}' > "${OUT_DIR}/pr_comment_payload.json" diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..23e4285 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,83 @@ +# Plan: Fix Static Map After Track Wrap + +## Problem + +When the ball reaches the end of the track, `physics.js` wraps the ball position back to the start (`ball.z = -halfLength + 1`), but the level (obstacles, coins, turtle) is **never regenerated**. This causes: + +1. **Same obstacles every lap** — obstacles stay in identical positions because `regenerateLevel()` is not called on wrap. +2. **No coins visible** — coins collected on the first pass remain hidden (their meshes have `visible = false` and the `coinsCollected` array still marks them as collected). +3. **Turtle missing** — once collected or passed, the turtle doesn't reappear. + +## Root Cause + +`js/physics.js:142-144` — the track wrap logic only resets `ball.z` but does nothing to refresh the level layout or reset collection state. There is no communication back to `main.js` that a wrap occurred. + +## Solution + +### Approach: Signal wrap event, regenerate in main.js + +The cleanest fix follows the existing pattern (similar to how `coinsCollected` and `turtleCollected` are communicated): + +1. **physics.js**: Add a `wrapped: true` flag to the return object of `updateOnTrack()` when the ball wraps around. + +2. **physics.js**: Add a new exported function `refreshLevel(config)` that updates obstacles, coins, and turtle references (and resets their collection state) without resetting ball position or velocity. + +3. **main.js**: When `result.wrapped` is true, call `regenerateLevel()` to create new obstacle/coin/turtle meshes, then call `refreshLevel()` with the new layout data. + +### Detailed Changes + +#### js/physics.js + +1. Add `wrapped` boolean tracking in `updateOnTrack()`: + - Set `wrapped = true` when `ball.z > halfLength` triggers the wrap. + - Include `wrapped` in the return object (default `false`). + - Also return `wrapped: false` from `updateFalling()`. + +2. Add new export `refreshLevel(config)`: + ```js + export function refreshLevel(config) { + obstacles = config.obstacles || []; + coins = config.coins || []; + coinsCollected = new Array(coins.length).fill(false); + turtle = config.turtle || null; + turtleCollected = false; + } + ``` + This updates level data without touching ball state or slowdown timers. + +#### js/main.js + +1. Import `refreshLevel` from `physics.js`. +2. In the game loop, after `updatePhysics()`, check `result.wrapped`: + ```js + if (result.wrapped) { + regenerateLevel(); + const newConfig = getTrackConfig(); + newConfig.obstacles = getObstacles(); + newConfig.coins = getCoins(); + newConfig.turtle = getTurtle(); + refreshLevel(newConfig); + } + ``` + +### Why not other approaches? + +- **Regenerate inside physics.js**: Physics shouldn't know about rendering. The existing architecture separates concerns. +- **Just reset coinsCollected on wrap**: Would show the same layout forever (same obstacle positions). The description says "the map becomes the same" which implies it should differ. +- **Use `initPhysics` on wrap**: Would reset ball position to start, causing a visual teleport and losing slowdown state. + +## File Changes Summary + +| File | Change | +|------|--------| +| `js/physics.js` | Add `wrapped` flag to return objects; add `refreshLevel()` export | +| `js/main.js` | Import `refreshLevel`; handle `result.wrapped` by regenerating level | + +## Verification + +- `docker build -t teeter .` must succeed +- After the ball reaches the end of the track and wraps, obstacles should appear in different positions +- Coins should be visible after wrapping (fresh coins in new positions) +- Turtle powerup should reappear after wrapping +- Score should persist across wraps (not reset to 0) +- Existing game-over/restart flow should still work diff --git a/index.html b/index.html index 2c8fbac..06b3a4e 100644 --- a/index.html +++ b/index.html @@ -103,6 +103,11 @@ #gameover-box .go-score { font-size: 1.4em; opacity: 0.8; + margin-bottom: 8px; + } + #gameover-box .go-time { + font-size: 1.2em; + opacity: 0.7; margin-bottom: 24px; } #gameover-box .go-message { @@ -224,6 +229,23 @@ #leaderboard-close:hover { background: rgba(255,255,255,0.1); } + #timer { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 1.4em; + font-weight: 700; + z-index: 10; + pointer-events: none; + text-shadow: 0 2px 4px rgba(0,0,0,0.5); + background: rgba(0,0,0,0.3); + padding: 6px 14px; + border-radius: 8px; + display: none; + } #slowdown-indicator { position: fixed; bottom: 50px; @@ -267,6 +289,7 @@
Score: 0
+
0.0s
TEETER
@@ -276,6 +299,7 @@
GAME OVER
+
diff --git a/js/main.js b/js/main.js index c84fdd2..c4b2189 100644 --- a/js/main.js +++ b/js/main.js @@ -9,7 +9,6 @@ import { getObstacles, getCoins, hideCoin, - showAllCoins, updateCoinRotation, regenerateLevel, getTurtle, @@ -17,15 +16,18 @@ import { } from './renderer.js'; import { initTracker, calibrate, detectTilt, detectPitch, resetTilt } from './tracker.js'; -import { initPhysics, updatePhysics, resetBall, refreshLevel } from './physics.js'; +import { initPhysics, updatePhysics } from './physics.js'; const overlay = document.getElementById('overlay'); const subtitle = overlay.querySelector('.subtitle'); const scoreEl = document.getElementById('score'); +const timerEl = document.getElementById('timer'); const leaderboardBtn = document.getElementById('leaderboard-btn'); const gameoverOverlay = document.getElementById('gameover-overlay'); +const gameoverTitle = gameoverOverlay.querySelector('.go-title'); const gameoverScore = gameoverOverlay.querySelector('.go-score'); const gameoverMessage = gameoverOverlay.querySelector('.go-message'); +const gameoverTime = gameoverOverlay.querySelector('.go-time'); const nameEntry = document.getElementById('name-entry'); const nameInput = document.getElementById('name-input'); const nameSubmit = document.getElementById('name-submit'); @@ -37,18 +39,35 @@ const slowdownIndicator = document.getElementById('slowdown-indicator'); const STORAGE_KEY = 'teeter_highscores'; const MAX_SCORES = 10; const NON_QUALIFYING_DELAY = 2000; +const FINISH_DISPLAY_DELAY = 3000; -let state = 'loading'; // loading | permission | playing | falling | gameover +let state = 'loading'; // loading | permission | playing | falling | finished | gameover let lastTime = 0; let resetTimer = null; let score = 0; let finalScore = 0; +let runStartTime = 0; +let runElapsed = 0; function updateScore(value) { score = value; scoreEl.textContent = 'Score: ' + score; } +function formatTime(seconds) { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + const ms = Math.floor((seconds % 1) * 10); + if (mins > 0) { + return mins + ':' + String(secs).padStart(2, '0') + '.' + ms; + } + return secs + '.' + ms + 's'; +} + +function updateTimerDisplay() { + timerEl.textContent = formatTime(runElapsed); +} + // --- localStorage leaderboard --- function loadScores() { @@ -70,7 +89,7 @@ function saveScores(scores) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(scores)); } catch { - // storage unavailable — silently fail + // storage unavailable } } @@ -125,13 +144,39 @@ function hideLeaderboard() { leaderboardPanel.classList.remove('visible'); } -// --- Game over flow --- +// --- Finish & Game over flow --- + +function enterFinished() { + finalScore = score; + state = 'finished'; + + gameoverTitle.textContent = 'COURSE COMPLETE!'; + gameoverScore.textContent = 'Score: ' + finalScore; + gameoverTime.textContent = 'Time: ' + formatTime(runElapsed); + + if (scoreQualifies(finalScore)) { + gameoverMessage.textContent = 'New high score!'; + nameEntry.classList.add('visible'); + nameInput.value = ''; + nameInput.focus(); + } else { + gameoverMessage.textContent = 'Well done!'; + nameEntry.classList.remove('visible'); + resetTimer = setTimeout(() => { + exitGameOver(); + }, FINISH_DISPLAY_DELAY); + } + + gameoverOverlay.classList.add('visible'); +} function enterGameOver() { finalScore = score; state = 'gameover'; + gameoverTitle.textContent = 'GAME OVER'; gameoverScore.textContent = 'Score: ' + finalScore; + gameoverTime.textContent = 'Time: ' + formatTime(runElapsed); if (scoreQualifies(finalScore)) { gameoverMessage.textContent = 'New high score!'; @@ -141,7 +186,6 @@ function enterGameOver() { } else { gameoverMessage.textContent = ''; nameEntry.classList.remove('visible'); - // Auto-dismiss after delay resetTimer = setTimeout(() => { exitGameOver(); }, NON_QUALIFYING_DELAY); @@ -178,8 +222,15 @@ function exitGameOver() { calibrate(performance.now()); resetBallRotation(); updateScore(0); - updateBallPosition(0, config.trackHeight / 2 + config.ballRadius, config.ballStartZ); - updateCamera(config.ballStartZ); + + // Reset ball to start of curve + const startPos = config.curveLocalToWorld(0, 0, config.ballRadius); + updateBallPosition(startPos.x, startPos.y, startPos.z); + updateCamera(0, startPos); + + runStartTime = performance.now(); + runElapsed = 0; + updateTimerDisplay(); state = 'playing'; } @@ -203,7 +254,6 @@ leaderboardClose.addEventListener('click', () => { hideLeaderboard(); }); -// Close leaderboard on backdrop click leaderboardPanel.addEventListener('click', (e) => { if (e.target === leaderboardPanel) { hideLeaderboard(); @@ -214,22 +264,18 @@ leaderboardPanel.addEventListener('click', (e) => { async function init() { try { - // Initialize Three.js renderer initRenderer(); const config = getTrackConfig(); - // Attach obstacle, coin, and turtle data to config for physics config.obstacles = getObstacles(); config.coins = getCoins(); config.turtle = getTurtle(); initPhysics(config); - // Initial render so the scene is visible during loading render(); subtitle.textContent = 'Requesting camera access...'; - // Request camera let stream; try { stream = await navigator.mediaDevices.getUserMedia({ @@ -242,7 +288,6 @@ async function init() { subtitle.textContent = 'Loading head tracking model...'; - // Initialize head tracker await initTracker(stream); // Calibrate neutral head position @@ -251,8 +296,12 @@ async function init() { // Hide overlay, show score and leaderboard button, and start game overlay.classList.add('hidden'); scoreEl.style.display = 'block'; + timerEl.style.display = 'block'; leaderboardBtn.style.display = 'block'; updateScore(0); + runStartTime = performance.now(); + runElapsed = 0; + updateTimerDisplay(); state = 'playing'; lastTime = performance.now(); requestAnimationFrame(gameLoop); @@ -276,22 +325,24 @@ function gameLoop(timestamp) { lastTime = timestamp; if (state === 'playing' || state === 'falling') { - // Get head tilt and pitch + // Update run timer + runElapsed = (timestamp - runStartTime) / 1000; + updateTimerDisplay(); + const tiltAngle = detectTilt(timestamp); const pitch = detectPitch(); - // Update physics const result = updatePhysics(dt, tiltAngle, pitch); - // Update renderer updateBallPosition(result.x, result.y, result.z); updateBallRotation(result.vx, result.vz, dt); - updateCamera(result.z); - // Animate coins + // Camera follows curve tangent at ball's t position + const ballWorldPos = { x: result.x, y: result.y, z: result.z }; + updateCamera(result.t, ballWorldPos); + updateCoinRotation(dt); - // Handle coin collection if (result.coinsCollected && result.coinsCollected.length > 0) { for (const idx of result.coinsCollected) { hideCoin(idx); @@ -299,7 +350,6 @@ function gameLoop(timestamp) { } } - // Handle turtle collection if (result.turtleCollected) { hideTurtle(); } @@ -311,18 +361,9 @@ function gameLoop(timestamp) { slowdownIndicator.classList.remove('visible'); } - // Handle track completion — regenerate level with fresh coins - if (result.trackCompleted) { - regenerateLevel(); - const config = getTrackConfig(); - config.obstacles = getObstacles(); - config.coins = getCoins(); - config.turtle = getTurtle(); - initPhysics(config); - resetBallRotation(); - slowdownIndicator.classList.remove('visible'); - updateBallPosition(0, config.trackHeight / 2 + config.ballRadius, config.ballStartZ); - updateCamera(config.ballStartZ); + // Handle finish line crossing + if (result.finished && state === 'playing') { + enterFinished(); } // Handle state transitions diff --git a/js/physics.js b/js/physics.js index b3777cc..b262ce0 100644 --- a/js/physics.js +++ b/js/physics.js @@ -4,10 +4,13 @@ const RESPONSE_RATE = 6.0; const FORWARD_SPEED = 2.0; const PITCH_SENSITIVITY = 3.0; const MAX_SPEED = 6.0; -const MAX_DT = 1 / 30; // Cap delta time to prevent physics explosions -const COIN_COLLECT_RADIUS = 0.8; -const TURTLE_COLLECT_RADIUS = 0.8; +const MAX_DT = 1 / 30; +const COIN_COLLECT_RADIUS = 0.6; // In lateral-distance space +const COIN_COLLECT_T_RADIUS = 0.005; // In t-space +const TURTLE_COLLECT_RADIUS = 0.6; +const TURTLE_COLLECT_T_RADIUS = 0.005; const SLOWDOWN_DURATION = 4; +const OBSTACLE_COLLISION_T_RADIUS = 0.004; let ball = {}; let trackConfig = {}; @@ -33,14 +36,25 @@ export function initPhysics(config) { export function resetBall() { ball = { - x: 0, - y: trackConfig.trackHeight / 2 + trackConfig.ballRadius, - z: trackConfig.ballStartZ, - vx: 0, - vy: 0, - vz: FORWARD_SPEED, + t: 0, // Position along curve (0-1) + d: 0, // Lateral offset from centerline + speed: FORWARD_SPEED, // Forward speed in world units/sec + lateralSpeed: 0, // Lateral speed falling: false, + vy: 0, // Vertical velocity when falling + worldX: 0, + worldY: 0, + worldZ: 0, }; + + // Compute initial world position + if (trackConfig.curveLocalToWorld) { + const pos = trackConfig.curveLocalToWorld(0, 0, trackConfig.ballRadius); + ball.worldX = pos.x; + ball.worldY = pos.y; + ball.worldZ = pos.z; + } + coinsCollected = new Array(coins.length).fill(false); turtleCollected = false; slowdownActive = false; @@ -58,6 +72,9 @@ export function updatePhysics(dt, tiltAngle, pitch) { } function updateOnTrack(dt, tiltAngle, pitch) { + const { curve, curveLength, curveLocalToWorld, trackWidth, trackHeight, ballRadius } = trackConfig; + if (!curve) return getFallbackResult(); + // Decrement slowdown timer if (slowdownActive) { slowdownTimer -= dt; @@ -67,41 +84,45 @@ function updateOnTrack(dt, tiltAngle, pitch) { } } - // Effective speeds (halved when slowed) const effectiveForward = slowdownActive ? FORWARD_SPEED / 2 : FORWARD_SPEED; const effectiveMax = slowdownActive ? MAX_SPEED / 2 : MAX_SPEED; - // Direct lateral velocity from head tilt with smooth interpolation - const targetVx = -tiltAngle * DIRECT_SENSITIVITY; - ball.vx += (targetVx - ball.vx) * RESPONSE_RATE * dt; + // Get tangent at current position for slope calculation + const clampedT = Math.max(0, Math.min(1, ball.t)); + const tangent = curve.getTangentAt(clampedT); + + // Gravity slope boost — tangent.y < 0 means going downhill + const gravityBoost = -GRAVITY * tangent.y * 0.3; - // Forward motion modulated by pitch (forward tilt speeds up, backward slows down) + // Forward motion: base speed + gravity + pitch modulation const pitchVal = pitch || 0; - ball.vz = Math.max(0, Math.min(effectiveMax, effectiveForward * (1 + pitchVal * PITCH_SENSITIVITY))); + const baseSpeed = effectiveForward * (1 + pitchVal * PITCH_SENSITIVITY); + ball.speed = Math.max(0.5, Math.min(effectiveMax, baseSpeed + gravityBoost)); + + // Lateral movement from head tilt + const targetLateral = tiltAngle * DIRECT_SENSITIVITY; + ball.lateralSpeed += (targetLateral - ball.lateralSpeed) * RESPONSE_RATE * dt; - // Update position - ball.x += ball.vx * dt; - ball.z += ball.vz * dt; + // Update curve-local position + ball.t += (ball.speed * dt) / curveLength; + ball.d += ball.lateralSpeed * dt; - // Track boundaries — check if ball center has gone past track edge - const halfWidth = trackConfig.trackWidth / 2; - if (Math.abs(ball.x) > halfWidth) { + // Edge detection — fall off if past track edge + const halfWidth = trackWidth / 2; + if (Math.abs(ball.d) > halfWidth) { ball.falling = true; ball.vy = 0; } - // Obstacle collision — AABB check with ball radius margin + // Obstacle collision in curve-local space let obstacleHit = false; if (!ball.falling) { - const br = trackConfig.ballRadius; for (let i = 0; i < obstacles.length; i++) { const o = obstacles[i]; - if ( - ball.x + br > o.x - o.halfW && - ball.x - br < o.x + o.halfW && - ball.z + br > o.z - o.halfD && - ball.z - br < o.z + o.halfD - ) { + const tDist = Math.abs(ball.t - o.t); + const dDist = Math.abs(ball.d - o.d); + + if (tDist < OBSTACLE_COLLISION_T_RADIUS && dDist < o.halfW + ballRadius * 0.5) { ball.falling = true; ball.vy = 0; obstacleHit = true; @@ -110,26 +131,24 @@ function updateOnTrack(dt, tiltAngle, pitch) { } } - // Coin collection — distance check in XZ plane + // Coin collection in curve-local space const newlyCollected = []; for (let i = 0; i < coins.length; i++) { if (coinsCollected[i]) continue; - const dx = ball.x - coins[i].x; - const dz = ball.z - coins[i].z; - const dist = Math.sqrt(dx * dx + dz * dz); - if (dist < COIN_COLLECT_RADIUS) { + const tDist = Math.abs(ball.t - coins[i].t); + const dDist = Math.abs(ball.d - coins[i].d); + if (tDist < COIN_COLLECT_T_RADIUS && dDist < COIN_COLLECT_RADIUS) { coinsCollected[i] = true; newlyCollected.push(i); } } - // Turtle collection — distance check in XZ plane + // Turtle collection let turtleJustCollected = false; if (turtle && !turtleCollected) { - const dx = ball.x - turtle.x; - const dz = ball.z - turtle.z; - const dist = Math.sqrt(dx * dx + dz * dz); - if (dist < TURTLE_COLLECT_RADIUS) { + const tDist = Math.abs(ball.t - turtle.t); + const dDist = Math.abs(ball.d - turtle.d); + if (tDist < TURTLE_COLLECT_T_RADIUS && dDist < TURTLE_COLLECT_RADIUS) { turtleCollected = true; turtleJustCollected = true; slowdownActive = true; @@ -137,53 +156,65 @@ function updateOnTrack(dt, tiltAngle, pitch) { } } - // Track end — wrap back to start if ball reaches the end - let trackCompleted = false; - const halfLength = trackConfig.trackLength / 2; - if (ball.z > halfLength) { - ball.z = -halfLength + 1; - trackCompleted = true; + // Finish line — ball crossed the end of the track + let finished = false; + if (ball.t >= 1.0) { + ball.t = 1.0; + ball.speed = 0; + ball.lateralSpeed = 0; + finished = true; } + // Convert curve-local to world position + const safeT = Math.max(0, Math.min(0.9999, ball.t)); + const worldPos = curveLocalToWorld(safeT, ball.d, ballRadius); + ball.worldX = worldPos.x; + ball.worldY = worldPos.y; + ball.worldZ = worldPos.z; + return { - x: ball.x, - y: ball.y, - z: ball.z, - vx: ball.vx, - vz: ball.vz, + x: ball.worldX, + y: ball.worldY, + z: ball.worldZ, + vx: ball.lateralSpeed, + vz: ball.speed, + t: ball.t, + d: ball.d, falling: ball.falling, needsReset: false, obstacleHit, + finished, coinsCollected: newlyCollected, turtleCollected: turtleJustCollected, slowdownActive, - trackCompleted, }; } function updateFalling(dt) { ball.vy -= GRAVITY * dt; - ball.y += ball.vy * dt; + ball.worldY += ball.vy * dt; - // Also continue lateral and forward motion slightly - ball.x += ball.vx * dt * 0.5; - ball.z += ball.vz * dt * 0.3; + // Continue lateral and forward drift + ball.worldX += ball.lateralSpeed * dt * 0.5; + ball.worldZ += ball.speed * dt * 0.3; - const needsReset = ball.y < -10; + const needsReset = ball.worldY < -10; return { - x: ball.x, - y: ball.y, - z: ball.z, - vx: ball.vx, - vz: ball.vz, + x: ball.worldX, + y: ball.worldY, + z: ball.worldZ, + vx: ball.lateralSpeed, + vz: ball.speed, + t: ball.t, + d: ball.d, falling: true, needsReset, obstacleHit: false, + finished: false, coinsCollected: [], turtleCollected: false, slowdownActive, - trackCompleted: false, }; } @@ -195,6 +226,17 @@ export function refreshLevel(config) { turtleCollected = false; } +function getFallbackResult() { + return { + x: 0, y: 0, z: 0, + vx: 0, vz: 0, + t: 0, d: 0, + falling: false, needsReset: false, + obstacleHit: false, finished: false, coinsCollected: [], turtleCollected: false, + slowdownActive: false, + }; +} + export function getBallState() { return { ...ball }; } diff --git a/js/renderer.js b/js/renderer.js index 7a027aa..0a46a02 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -2,36 +2,70 @@ import * as THREE from 'three'; const TRACK_WIDTH = 4.5; const TRACK_HEIGHT = 0.2; -const TRACK_LENGTH = 50; const BALL_RADIUS = 0.3; -const BALL_START_Z = -20; // Obstacle config const OBSTACLE_WIDTH = 1.5; const OBSTACLE_HEIGHT = 1.0; const OBSTACLE_DEPTH = 0.4; -const OBSTACLE_MIN_SPACING = 7; -const OBSTACLE_MAX_SPACING = 9; -const SAFE_ZONE_Z = BALL_START_Z + 5; // No obstacles/coins before Z = -15 -const MIN_GAP = 1.5; // Minimum passable gap beside obstacle +const OBSTACLE_MIN_SPACING = 0.04; // In t-space (~5.6 world units on 140-unit curve) +const OBSTACLE_MAX_SPACING = 0.06; +const SAFE_ZONE_T = 0.05; // No obstacles before 5% of curve +const MIN_GAP = 1.5; // Coin config const COIN_RADIUS = 0.25; const COIN_TUBE = 0.08; -const COIN_Y = TRACK_HEIGHT / 2 + 0.35; + +const NUM_TRACK_SAMPLES = 300; + +// Curve control points — winding, gently downhill path +const CONTROL_POINTS = [ + new THREE.Vector3(0, 10, 0), + new THREE.Vector3(0, 9.5, 10), + new THREE.Vector3(3, 8.5, 25), + new THREE.Vector3(5, 7.5, 40), + new THREE.Vector3(3, 6.5, 55), + new THREE.Vector3(-3, 5.5, 70), + new THREE.Vector3(-5, 4.5, 85), + new THREE.Vector3(-2, 3.0, 100), + new THREE.Vector3(2, 1.5, 115), + new THREE.Vector3(2, 0.5, 130), + new THREE.Vector3(0, 0, 140), +]; + +let curve = null; +let curveLength = 0; let scene, camera, renderer; -let trackMesh, ballMesh; -let edgeLeft, edgeRight; +let ballMesh; +let trackGroup; +let finishLineMesh; let obstacleMeshes = []; -let obstacleData = []; // { x, z, halfW, halfD } +let obstacleData = []; let coinMeshes = []; -let coinData = []; // { x, z } +let coinData = []; let turtleMesh = null; -let turtleData = null; // { x, z } or null +let turtleData = null; + +// Shared geometry and materials +const obstGeo = new THREE.BoxGeometry(OBSTACLE_WIDTH, OBSTACLE_HEIGHT, OBSTACLE_DEPTH); +const obstMat = new THREE.MeshStandardMaterial({ + color: 0x8B2222, + roughness: 0.5, + metalness: 0.2, +}); +const coinGeo = new THREE.TorusGeometry(COIN_RADIUS, COIN_TUBE, 12, 24); +const coinMat = new THREE.MeshStandardMaterial({ + color: 0xFFD700, + metalness: 0.8, + roughness: 0.2, + emissive: 0x554400, + emissiveIntensity: 0.3, +}); -// Simple seeded RNG for deterministic placement +// Simple seeded RNG function seededRandom(seed) { let s = seed; return function () { @@ -40,83 +74,307 @@ function seededRandom(seed) { }; } -function generateObstacles(rng) { - const obstacles = []; - const halfTrack = TRACK_WIDTH / 2; - const halfLength = TRACK_LENGTH / 2; +function buildCurve() { + curve = new THREE.CatmullRomCurve3(CONTROL_POINTS, false, 'centripetal', 0.5); + curveLength = curve.getLength(); +} - let z = SAFE_ZONE_Z; - while (z < halfLength - 2) { - const spacing = OBSTACLE_MIN_SPACING + rng() * (OBSTACLE_MAX_SPACING - OBSTACLE_MIN_SPACING); - z += spacing; - if (z >= halfLength - 1) break; +// Get lateral vector at a point on the curve (perpendicular to tangent, in the horizontal-ish plane) +function getLateral(t) { + const tangent = curve.getTangentAt(t); + const up = new THREE.Vector3(0, 1, 0); + const lateral = new THREE.Vector3().crossVectors(tangent, up).normalize(); + // If tangent is nearly vertical, fallback + if (lateral.lengthSq() < 0.001) { + lateral.set(1, 0, 0); + } + return lateral; +} - // Place obstacle so there's at least MIN_GAP on one side - const maxOffset = halfTrack - OBSTACLE_WIDTH / 2 - 0.1; - const x = (rng() * 2 - 1) * maxOffset; +function getTrackUp(t) { + const tangent = curve.getTangentAt(t); + const lateral = getLateral(t); + return new THREE.Vector3().crossVectors(lateral, tangent).normalize(); +} - obstacles.push({ - x, - z, - halfW: OBSTACLE_WIDTH / 2, - halfD: OBSTACLE_DEPTH / 2, - }); +function buildTrackMesh() { + trackGroup = new THREE.Group(); + + const positions = []; + const normals = []; + const indices = []; + const uvs = []; + + const halfWidth = TRACK_WIDTH / 2; + + // Build ribbon geometry + for (let i = 0; i <= NUM_TRACK_SAMPLES; i++) { + const t = i / NUM_TRACK_SAMPLES; + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + + const left = point.clone().add(lateral.clone().multiplyScalar(-halfWidth)); + const right = point.clone().add(lateral.clone().multiplyScalar(halfWidth)); + + // Raise by track height/2 so surface is on top + const yOffset = trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2); + left.add(yOffset); + right.add(yOffset); + + positions.push(left.x, left.y, left.z); + positions.push(right.x, right.y, right.z); + + normals.push(trackUp.x, trackUp.y, trackUp.z); + normals.push(trackUp.x, trackUp.y, trackUp.z); + + uvs.push(0, t); + uvs.push(1, t); + + if (i < NUM_TRACK_SAMPLES) { + const base = i * 2; + indices.push(base, base + 1, base + 2); + indices.push(base + 1, base + 3, base + 2); + } } - return obstacles; -} -function generateCoins(rng, obstacles) { - const coins = []; - const halfTrack = TRACK_WIDTH / 2; - const halfLength = TRACK_LENGTH / 2; + // Also build underside for thickness + const topVertCount = (NUM_TRACK_SAMPLES + 1) * 2; + for (let i = 0; i <= NUM_TRACK_SAMPLES; i++) { + const t = i / NUM_TRACK_SAMPLES; + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); - // Place 2-3 coins between each pair of obstacles - for (let i = 0; i < obstacles.length; i++) { - const startZ = i === 0 ? SAFE_ZONE_Z : obstacles[i - 1].z + 1; - const endZ = obstacles[i].z - 1; - const gap = endZ - startZ; - if (gap < 2) continue; + const left = point.clone().add(lateral.clone().multiplyScalar(-halfWidth)); + const right = point.clone().add(lateral.clone().multiplyScalar(halfWidth)); - const count = gap >= 5 ? 3 : 2; - const step = gap / (count + 1); + const yOffset = trackUp.clone().multiplyScalar(-TRACK_HEIGHT / 2); + left.add(yOffset); + right.add(yOffset); - for (let j = 1; j <= count; j++) { - const cz = startZ + step * j; - const cx = (rng() * 2 - 1) * (halfTrack - 0.5); - coins.push({ x: cx, z: cz }); + positions.push(left.x, left.y, left.z); + positions.push(right.x, right.y, right.z); + + const downNorm = trackUp.clone().negate(); + normals.push(downNorm.x, downNorm.y, downNorm.z); + normals.push(downNorm.x, downNorm.y, downNorm.z); + + uvs.push(0, t); + uvs.push(1, t); + + if (i < NUM_TRACK_SAMPLES) { + const base = topVertCount + i * 2; + indices.push(base, base + 2, base + 1); + indices.push(base + 1, base + 2, base + 3); } } - // Coins after the last obstacle - if (obstacles.length > 0) { - const lastZ = obstacles[obstacles.length - 1].z + 1; - const gap = halfLength - lastZ; - if (gap >= 3) { - const count = 2; - const step = gap / (count + 1); - for (let j = 1; j <= count; j++) { - const cz = lastZ + step * j; - const cx = (rng() * 2 - 1) * (halfTrack - 0.5); - coins.push({ x: cx, z: cz }); - } + // Side faces (left edge and right edge) + const sideStart = positions.length / 3; + for (let i = 0; i <= NUM_TRACK_SAMPLES; i++) { + const t = i / NUM_TRACK_SAMPLES; + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + + const halfH = TRACK_HEIGHT / 2; + // Left edge + const leftTop = point.clone() + .add(lateral.clone().multiplyScalar(-halfWidth)) + .add(trackUp.clone().multiplyScalar(halfH)); + const leftBot = point.clone() + .add(lateral.clone().multiplyScalar(-halfWidth)) + .add(trackUp.clone().multiplyScalar(-halfH)); + + const leftNorm = lateral.clone().negate(); + + positions.push(leftTop.x, leftTop.y, leftTop.z); + positions.push(leftBot.x, leftBot.y, leftBot.z); + normals.push(leftNorm.x, leftNorm.y, leftNorm.z); + normals.push(leftNorm.x, leftNorm.y, leftNorm.z); + uvs.push(0, t); + uvs.push(0, t); + + if (i < NUM_TRACK_SAMPLES) { + const base = sideStart + i * 2; + indices.push(base, base + 2, base + 1); + indices.push(base + 1, base + 2, base + 3); } } - // Guarantee at least one coin on the track - if (coins.length === 0) { - const safeStart = SAFE_ZONE_Z + 1; - const safeEnd = halfLength - 2; - const range = safeEnd - safeStart; - const count = Math.max(3, Math.floor(range / 5)); - const step = range / (count + 1); - for (let j = 1; j <= count; j++) { - const cz = safeStart + step * j; - const cx = (rng() * 2 - 1) * (halfTrack - 0.5); - coins.push({ x: cx, z: cz }); + const rightStart = positions.length / 3; + for (let i = 0; i <= NUM_TRACK_SAMPLES; i++) { + const t = i / NUM_TRACK_SAMPLES; + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + + const halfH = TRACK_HEIGHT / 2; + const rightTop = point.clone() + .add(lateral.clone().multiplyScalar(halfWidth)) + .add(trackUp.clone().multiplyScalar(halfH)); + const rightBot = point.clone() + .add(lateral.clone().multiplyScalar(halfWidth)) + .add(trackUp.clone().multiplyScalar(-halfH)); + + positions.push(rightTop.x, rightTop.y, rightTop.z); + positions.push(rightBot.x, rightBot.y, rightBot.z); + normals.push(lateral.x, lateral.y, lateral.z); + normals.push(lateral.x, lateral.y, lateral.z); + uvs.push(1, t); + uvs.push(1, t); + + if (i < NUM_TRACK_SAMPLES) { + const base = rightStart + i * 2; + indices.push(base, base + 1, base + 2); + indices.push(base + 1, base + 3, base + 2); } } - return coins; + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); + geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + geo.setIndex(indices); + + const trackMat = new THREE.MeshStandardMaterial({ + color: 0x8B7355, + roughness: 0.7, + metalness: 0.1, + side: THREE.DoubleSide, + }); + + const trackMesh = new THREE.Mesh(geo, trackMat); + trackMesh.receiveShadow = true; + trackGroup.add(trackMesh); + + // Edge lines + const edgeMat = new THREE.MeshStandardMaterial({ color: 0x5a4a3a, roughness: 0.6 }); + const edgeRadius = 0.04; + const edgeSegments = NUM_TRACK_SAMPLES; + + // Build edge line as a tube along left and right edges + const leftPoints = []; + const rightPoints = []; + for (let i = 0; i <= edgeSegments; i++) { + const t = i / edgeSegments; + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + const yOff = trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2 + edgeRadius); + + leftPoints.push(point.clone().add(lateral.clone().multiplyScalar(-halfWidth)).add(yOff)); + rightPoints.push(point.clone().add(lateral.clone().multiplyScalar(halfWidth)).add(yOff)); + } + + const leftCurve = new THREE.CatmullRomCurve3(leftPoints); + const rightCurve = new THREE.CatmullRomCurve3(rightPoints); + + const edgeGeoL = new THREE.TubeGeometry(leftCurve, edgeSegments, edgeRadius, 6, false); + const edgeGeoR = new THREE.TubeGeometry(rightCurve, edgeSegments, edgeRadius, 6, false); + + const edgeLeft = new THREE.Mesh(edgeGeoL, edgeMat); + const edgeRight = new THREE.Mesh(edgeGeoR, edgeMat); + trackGroup.add(edgeLeft); + trackGroup.add(edgeRight); + + scene.add(trackGroup); +} + +function buildFinishLine() { + // Create a checkerboard texture via canvas + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 32; + const ctx = canvas.getContext('2d'); + const numChecks = 8; + const checkW = canvas.width / numChecks; + const checkH = canvas.height / 2; + for (let row = 0; row < 2; row++) { + for (let col = 0; col < numChecks; col++) { + ctx.fillStyle = (row + col) % 2 === 0 ? '#ffffff' : '#111111'; + ctx.fillRect(col * checkW, row * checkH, checkW, checkH); + } + } + const texture = new THREE.CanvasTexture(canvas); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + + const finishGeo = new THREE.PlaneGeometry(TRACK_WIDTH, 1.5); + const finishMat = new THREE.MeshStandardMaterial({ + map: texture, + roughness: 0.4, + metalness: 0.1, + side: THREE.DoubleSide, + }); + finishLineMesh = new THREE.Mesh(finishGeo, finishMat); + + // Position at end of curve + const endPoint = curve.getPointAt(1.0); + const tangent = curve.getTangentAt(1.0); + const lateral = getLateral(1.0); + const trackUp = getTrackUp(1.0); + + finishLineMesh.position.copy(endPoint); + finishLineMesh.position.add(trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2 + 0.01)); + + // Orient to face along tangent, lying on track surface + const lookTarget = endPoint.clone().add(trackUp); + finishLineMesh.lookAt(lookTarget); + // Rotate to align width with lateral direction + const quat = new THREE.Quaternion(); + const mat4 = new THREE.Matrix4(); + mat4.makeBasis(lateral, trackUp, tangent); + quat.setFromRotationMatrix(mat4); + finishLineMesh.quaternion.copy(quat); + // Shift slightly up off surface + finishLineMesh.position.add(trackUp.clone().multiplyScalar(0.02)); + + scene.add(finishLineMesh); + + // Add vertical finish banner poles + const poleMat = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.5 }); + const poleGeo = new THREE.CylinderGeometry(0.05, 0.05, 2.5, 8); + const poleLeft = new THREE.Mesh(poleGeo, poleMat); + const poleRight = new THREE.Mesh(poleGeo, poleMat); + + const poleBase = endPoint.clone().add(trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2 + 1.25)); + poleLeft.position.copy(poleBase.clone().add(lateral.clone().multiplyScalar(-TRACK_WIDTH / 2))); + poleRight.position.copy(poleBase.clone().add(lateral.clone().multiplyScalar(TRACK_WIDTH / 2))); + + // Align poles with track up direction + const poleQuat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), trackUp); + poleLeft.quaternion.copy(poleQuat); + poleRight.quaternion.copy(poleQuat); + + scene.add(poleLeft); + scene.add(poleRight); + + // Banner across top + const bannerGeo = new THREE.PlaneGeometry(TRACK_WIDTH, 0.4); + const bannerCanvas = document.createElement('canvas'); + bannerCanvas.width = 256; + bannerCanvas.height = 32; + const bctx = bannerCanvas.getContext('2d'); + // Checkerboard banner + for (let col = 0; col < 16; col++) { + bctx.fillStyle = col % 2 === 0 ? '#ffffff' : '#111111'; + bctx.fillRect(col * 16, 0, 16, 32); + } + const bannerTex = new THREE.CanvasTexture(bannerCanvas); + const bannerMat = new THREE.MeshStandardMaterial({ + map: bannerTex, + side: THREE.DoubleSide, + roughness: 0.4, + }); + const bannerMesh = new THREE.Mesh(bannerGeo, bannerMat); + bannerMesh.position.copy(poleBase.clone().add(trackUp.clone().multiplyScalar(1.25))); + const bannerQuat = new THREE.Quaternion(); + const bannerBasis = new THREE.Matrix4().makeBasis(lateral, trackUp, tangent); + bannerQuat.setFromRotationMatrix(bannerBasis); + bannerMesh.quaternion.copy(bannerQuat); + scene.add(bannerMesh); } function createTurtleMesh() { @@ -125,27 +383,23 @@ function createTurtleMesh() { const shellMat = new THREE.MeshStandardMaterial({ color: 0x185818, roughness: 0.5, metalness: 0.15 }); const headMat = new THREE.MeshStandardMaterial({ color: 0x2EA52E, roughness: 0.5, metalness: 0.1 }); - // Shell (flattened sphere) const shellGeo = new THREE.SphereGeometry(0.4, 16, 12); const shell = new THREE.Mesh(shellGeo, shellMat); shell.scale.set(1, 0.5, 1.1); shell.position.y = 0.1; group.add(shell); - // Body (slightly smaller, underneath shell) const bodyGeo = new THREE.SphereGeometry(0.35, 12, 10); const body = new THREE.Mesh(bodyGeo, bodyMat); body.scale.set(1, 0.35, 1.05); body.position.y = -0.02; group.add(body); - // Head (small sphere at front) const headGeo = new THREE.SphereGeometry(0.12, 10, 8); const head = new THREE.Mesh(headGeo, headMat); head.position.set(0, 0.05, 0.42); group.add(head); - // Legs (4 flattened cylinders) const legGeo = new THREE.CylinderGeometry(0.06, 0.06, 0.12, 6); const legPositions = [ { x: -0.22, z: 0.2 }, @@ -162,52 +416,120 @@ function createTurtleMesh() { return group; } +function generateObstacles(rng) { + const obstacles = []; + const halfTrack = TRACK_WIDTH / 2; + + let t = SAFE_ZONE_T; + const endT = 0.95; // Stop before finish line + while (t < endT) { + const spacing = OBSTACLE_MIN_SPACING + rng() * (OBSTACLE_MAX_SPACING - OBSTACLE_MIN_SPACING); + t += spacing; + if (t >= endT) break; + + // Place obstacle with lateral offset + const maxOffset = halfTrack - OBSTACLE_WIDTH / 2 - 0.1; + const d = (rng() * 2 - 1) * maxOffset; + + // Convert to world position for mesh placement + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + const tangent = curve.getTangentAt(t); + + const worldPos = point.clone() + .add(lateral.clone().multiplyScalar(d)) + .add(trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2 + OBSTACLE_HEIGHT / 2)); + + obstacles.push({ + t, + d, + halfW: OBSTACLE_WIDTH / 2, + halfD: OBSTACLE_DEPTH / 2, + worldPos, + tangent: tangent.clone(), + lateral: lateral.clone(), + trackUp: trackUp.clone(), + }); + } + return obstacles; +} + +function generateCoins(rng, obstacles) { + const coins = []; + const halfTrack = TRACK_WIDTH / 2; + + for (let i = 0; i < obstacles.length; i++) { + const startT = i === 0 ? SAFE_ZONE_T : obstacles[i - 1].t + 0.005; + const endT = obstacles[i].t - 0.005; + const gap = endT - startT; + if (gap < 0.01) continue; + + const count = gap >= 0.03 ? 3 : 2; + const step = gap / (count + 1); + + for (let j = 1; j <= count; j++) { + const ct = startT + step * j; + const cd = (rng() * 2 - 1) * (halfTrack - 0.5); + coins.push({ t: ct, d: cd }); + } + } + + // Coins after last obstacle + if (obstacles.length > 0) { + const lastT = obstacles[obstacles.length - 1].t + 0.005; + const gap = 0.95 - lastT; + if (gap >= 0.015) { + const count = 2; + const step = gap / (count + 1); + for (let j = 1; j <= count; j++) { + const ct = lastT + step * j; + const cd = (rng() * 2 - 1) * (halfTrack - 0.5); + coins.push({ t: ct, d: cd }); + } + } + } + + return coins; +} + function generateTurtle(rng, obstacles) { const halfTrack = TRACK_WIDTH / 2; - const halfLength = TRACK_LENGTH / 2; - const minZ = SAFE_ZONE_Z + 5; - const maxZ = halfLength - 3; + const minT = SAFE_ZONE_T + 0.05; + const maxT = 0.90; - if (maxZ <= minZ) return null; + if (maxT <= minT) return null; - // Pick a random Z, avoiding obstacle zones let attempts = 0; while (attempts < 20) { - const z = minZ + rng() * (maxZ - minZ); + const t = minT + rng() * (maxT - minT); let clear = true; for (const o of obstacles) { - if (Math.abs(z - o.z) < 2) { + if (Math.abs(t - o.t) < 0.02) { clear = false; break; } } if (clear) { - const x = (rng() * 2 - 1) * (halfTrack - 0.5); - return { x, z }; + const d = (rng() * 2 - 1) * (halfTrack - 0.5); + return { t, d }; } attempts++; } - // Fallback: place in safe zone area - const x = (rng() * 2 - 1) * (halfTrack - 0.5); - return { x, z: minZ + 2 }; + const d = (rng() * 2 - 1) * (halfTrack - 0.5); + return { t: minT + 0.02, d }; } -// Shared geometry and materials for obstacles and coins -const obstGeo = new THREE.BoxGeometry(OBSTACLE_WIDTH, OBSTACLE_HEIGHT, OBSTACLE_DEPTH); -const obstMat = new THREE.MeshStandardMaterial({ - color: 0x8B2222, - roughness: 0.5, - metalness: 0.2, -}); -const coinGeo = new THREE.TorusGeometry(COIN_RADIUS, COIN_TUBE, 12, 24); -const coinMat = new THREE.MeshStandardMaterial({ - color: 0xFFD700, - metalness: 0.8, - roughness: 0.2, - emissive: 0x554400, - emissiveIntensity: 0.3, -}); +// Convert curve-local (t, d) to world position on the track surface +function curveLocalToWorld(t, d, yOffset) { + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + return point.clone() + .add(lateral.clone().multiplyScalar(d)) + .add(trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2 + (yOffset || 0))); +} function generateLevel() { let rng = seededRandom(Date.now()); @@ -225,65 +547,75 @@ function generateLevel() { obstacleMeshes = obstacleData.map((o) => { const mesh = new THREE.Mesh(obstGeo, obstMat); - mesh.position.set(o.x, TRACK_HEIGHT / 2 + OBSTACLE_HEIGHT / 2, o.z); + mesh.position.copy(o.worldPos); + + // Orient obstacle to align with track + const quat = new THREE.Quaternion(); + const basis = new THREE.Matrix4().makeBasis(o.lateral, o.trackUp, o.tangent); + quat.setFromRotationMatrix(basis); + mesh.quaternion.copy(quat); + mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); return mesh; }); + const coinY = 0.35; // Height above track surface coinMeshes = coinData.map((c) => { + const worldPos = curveLocalToWorld(c.t, c.d, coinY); const mesh = new THREE.Mesh(coinGeo, coinMat); - mesh.position.set(c.x, COIN_Y, c.z); + mesh.position.copy(worldPos); mesh.rotation.x = Math.PI / 2; scene.add(mesh); return mesh; }); - // Generate turtle powerup + // Turtle powerup turtleData = generateTurtle(rng, obstacleData); if (turtleData) { turtleMesh = createTurtleMesh(); - turtleMesh.position.set(turtleData.x, COIN_Y, turtleData.z); + const turtleWorldPos = curveLocalToWorld(turtleData.t, turtleData.d, 0.35); + turtleMesh.position.copy(turtleWorldPos); scene.add(turtleMesh); } } export function regenerateLevel() { - // Remove old obstacle meshes from scene for (const mesh of obstacleMeshes) { scene.remove(mesh); } obstacleMeshes = []; obstacleData = []; - // Remove old coin meshes from scene for (const mesh of coinMeshes) { scene.remove(mesh); } coinMeshes = []; coinData = []; - // Remove old turtle mesh from scene if (turtleMesh) { scene.remove(turtleMesh); turtleMesh = null; turtleData = null; } - // Generate fresh layout generateLevel(); } export function initRenderer() { scene = new THREE.Scene(); scene.background = new THREE.Color(0x87CEEB); - scene.fog = new THREE.Fog(0x87CEEB, 30, 80); + scene.fog = new THREE.Fog(0x87CEEB, 40, 120); + + // Build curve + buildCurve(); // Camera - camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200); - camera.position.set(0, 4, BALL_START_Z - 8); - camera.lookAt(0, 0, BALL_START_Z); + const startPoint = curve.getPointAt(0); + camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 300); + camera.position.set(startPoint.x, startPoint.y + 4, startPoint.z - 8); + camera.lookAt(startPoint); // Renderer renderer = new THREE.WebGLRenderer({ antialias: true }); @@ -298,40 +630,37 @@ export function initRenderer() { scene.add(ambient); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); - dirLight.position.set(5, 10, 5); + dirLight.position.set(5, 20, 5); dirLight.castShadow = true; - dirLight.shadow.mapSize.width = 1024; - dirLight.shadow.mapSize.height = 1024; + dirLight.shadow.mapSize.width = 2048; + dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 0.5; - dirLight.shadow.camera.far = 60; - dirLight.shadow.camera.left = -10; - dirLight.shadow.camera.right = 10; - dirLight.shadow.camera.top = 30; - dirLight.shadow.camera.bottom = -30; + dirLight.shadow.camera.far = 100; + dirLight.shadow.camera.left = -20; + dirLight.shadow.camera.right = 20; + dirLight.shadow.camera.top = 40; + dirLight.shadow.camera.bottom = -40; scene.add(dirLight); - // Track (fixed, never rotates) - const trackGeo = new THREE.BoxGeometry(TRACK_WIDTH, TRACK_HEIGHT, TRACK_LENGTH); - const trackMat = new THREE.MeshStandardMaterial({ - color: 0x8B7355, - roughness: 0.7, - metalness: 0.1, - }); - trackMesh = new THREE.Mesh(trackGeo, trackMat); - trackMesh.position.set(0, 0, 0); - trackMesh.receiveShadow = true; - scene.add(trackMesh); + // A second directional light for better illumination along the course + const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4); + dirLight2.position.set(-5, 15, 70); + scene.add(dirLight2); - // Edge lines for visibility - const edgeMat = new THREE.MeshStandardMaterial({ color: 0x5a4a3a, roughness: 0.6 }); - const edgeGeo = new THREE.BoxGeometry(0.06, 0.08, TRACK_LENGTH); - edgeLeft = new THREE.Mesh(edgeGeo, edgeMat); - edgeLeft.position.set(-TRACK_WIDTH / 2, TRACK_HEIGHT / 2 + 0.04, 0); - scene.add(edgeLeft); + // Ground plane (far below track, for visual reference) + const groundGeo = new THREE.PlaneGeometry(300, 300); + const groundMat = new THREE.MeshStandardMaterial({ color: 0x3a7d3a, roughness: 0.9 }); + const ground = new THREE.Mesh(groundGeo, groundMat); + ground.rotation.x = -Math.PI / 2; + ground.position.y = -5; + ground.receiveShadow = true; + scene.add(ground); + + // Build track mesh + buildTrackMesh(); - edgeRight = new THREE.Mesh(edgeGeo, edgeMat); - edgeRight.position.set(TRACK_WIDTH / 2, TRACK_HEIGHT / 2 + 0.04, 0); - scene.add(edgeRight); + // Build finish line + buildFinishLine(); // Ball const ballGeo = new THREE.SphereGeometry(BALL_RADIUS, 32, 32); @@ -342,10 +671,11 @@ export function initRenderer() { }); ballMesh = new THREE.Mesh(ballGeo, ballMat); ballMesh.castShadow = true; - ballMesh.position.set(0, TRACK_HEIGHT / 2 + BALL_RADIUS, BALL_START_Z); + const ballStart = curveLocalToWorld(0, 0, BALL_RADIUS); + ballMesh.position.copy(ballStart); scene.add(ballMesh); - // Generate initial level layout + // Generate level generateLevel(); // Handle resize @@ -369,15 +699,30 @@ export function resetBallRotation() { } export function updateBallRotation(vx, vz, dt) { - // Rolling rotation: x-axis for forward motion, z-axis for lateral ballMesh.rotation.x -= (vz / BALL_RADIUS) * dt; ballMesh.rotation.z += (vx / BALL_RADIUS) * dt; } -export function updateCamera(ballZ) { - camera.position.z = ballZ - 8; - camera.position.y = 4; - camera.lookAt(0, 0, ballZ); +// Camera smoothly follows the ball along the curve +const _cameraTarget = new THREE.Vector3(); +const _cameraPos = new THREE.Vector3(); + +export function updateCamera(ballT, ballWorldPos) { + if (!curve) return; + + const clampedT = Math.max(0, Math.min(1, ballT)); + const tangent = curve.getTangentAt(clampedT); + + // Camera positioned behind the ball along the tangent + _cameraPos.copy(ballWorldPos) + .sub(tangent.clone().multiplyScalar(8)) + .add(new THREE.Vector3(0, 4, 0)); + + // Smooth follow + camera.position.lerp(_cameraPos, 0.08); + + _cameraTarget.copy(ballWorldPos).add(new THREE.Vector3(0, 0.5, 0)); + camera.lookAt(_cameraTarget); } export function render() { @@ -388,16 +733,21 @@ export function getTrackConfig() { return { trackWidth: TRACK_WIDTH, trackHeight: TRACK_HEIGHT, - trackLength: TRACK_LENGTH, + trackLength: curveLength, ballRadius: BALL_RADIUS, - ballStartZ: BALL_START_Z, + ballStartT: 0, + curve, + curveLength, + getLateral, + getTrackUp, + curveLocalToWorld, }; } export function getObstacles() { return obstacleData.map((o) => ({ - x: o.x, - z: o.z, + t: o.t, + d: o.d, halfW: o.halfW, halfD: o.halfD, height: OBSTACLE_HEIGHT, @@ -405,7 +755,7 @@ export function getObstacles() { } export function getCoins() { - return coinData.map((c) => ({ x: c.x, z: c.z })); + return coinData.map((c) => ({ t: c.t, d: c.d })); } export function hideCoin(index) { @@ -414,24 +764,19 @@ export function hideCoin(index) { } } -export function showAllCoins() { - coinMeshes.forEach((m) => { m.visible = true; }); -} - export function updateCoinRotation(dt) { coinMeshes.forEach((m) => { if (m.visible) { m.rotation.y += 2.0 * dt; } }); - // Rotate turtle powerup too if (turtleMesh && turtleMesh.visible) { turtleMesh.rotation.y += 1.5 * dt; } } export function getTurtle() { - return turtleData ? { x: turtleData.x, z: turtleData.z } : null; + return turtleData ? { t: turtleData.t, d: turtleData.d } : null; } export function hideTurtle() { diff --git a/tasks.json b/tasks.json new file mode 100644 index 0000000..33579d2 --- /dev/null +++ b/tasks.json @@ -0,0 +1,21 @@ +{ + "mode": "single", + "claudeMd": "# Task: Fix Static Map After Track Wrap\n\nYou are fixing a bug in an existing Three.js ball-rolling game where the map becomes static (same obstacles, no coins) after the ball wraps around the end of the track.\n\n## Codebase\n\nPure static HTML+JS served by nginx. No npm, no build step. Three.js v0.183.2 via CDN importmap.\n\n- `index.html` — Main page with Three.js importmap, overlay UI, and all CSS\n- `js/main.js` — Game loop and state management (coin collection wiring, game-over flow)\n- `js/physics.js` — Ball physics, collision detection, coin/turtle collection checks\n- `js/renderer.js` — Three.js scene, mesh creation, level generation (obstacles + coins + turtle), seeded RNG\n- `js/tracker.js` — MediaPipe head tilt detection (DO NOT MODIFY)\n- `Dockerfile` + `nginx.conf` — Docker build serving on port 8080\n\n## Bug Description\n\nWhen the ball reaches the end of the track (z > halfLength), `physics.js:updateOnTrack()` wraps `ball.z` back to `-halfLength + 1` but does NOT regenerate the level. This means:\n1. Obstacles remain in the same positions every lap\n2. Coins that were collected (hidden) stay hidden — no new coins appear\n3. The turtle powerup, if collected, doesn't reappear\n\n## What to Implement\n\n### 1. physics.js — Add `wrapped` flag and `refreshLevel` export\n\n**Add `wrapped` tracking in `updateOnTrack()`:**\n- Add a `let wrapped = false;` at the top of the function.\n- In the existing wrap check (`if (ball.z > halfLength)`), set `wrapped = true`.\n- Add `wrapped` to the return object.\n- Also add `wrapped: false` to the `updateFalling()` return object.\n\n**Add new exported function `refreshLevel(config)`:**\n```js\nexport function refreshLevel(config) {\n obstacles = config.obstacles || [];\n coins = config.coins || [];\n coinsCollected = new Array(coins.length).fill(false);\n turtle = config.turtle || null;\n turtleCollected = false;\n}\n```\nThis updates level references without resetting ball position, velocity, or slowdown state.\n\n### 2. main.js — Handle wrap event\n\n**Import `refreshLevel` from `physics.js`:**\nAdd `refreshLevel` to the existing import from `'./physics.js'`.\n\n**In the game loop**, after the `updatePhysics()` call and before coin/turtle collection handling, add:\n```js\nif (result.wrapped) {\n regenerateLevel();\n const newConfig = getTrackConfig();\n newConfig.obstacles = getObstacles();\n newConfig.coins = getCoins();\n newConfig.turtle = getTurtle();\n refreshLevel(newConfig);\n}\n```\n\nThis triggers `regenerateLevel()` (which removes old meshes, generates new RNG-seeded layout, and creates new meshes), then passes the new obstacle/coin/turtle data to physics.\n\n## Conventions\n- 2-space indent throughout all files\n- ES module imports/exports\n- Match existing naming: camelCase for functions/variables, UPPER_SNAKE for constants\n- `let` for module-level mutable state, `const` for constants\n- Follow the existing return-object pattern in physics (see `coinsCollected`, `turtleCollected` fields)\n\n## Gotchas\n- Do NOT use `initPhysics()` on wrap — it resets ball position to `ballStartZ` and resets slowdown state. Use the new `refreshLevel()` instead.\n- `regenerateLevel()` in renderer.js already handles cleanup of old meshes and creation of new ones with a fresh `Date.now()` seed. No changes needed there.\n- The `wrapped` flag should only be true for the single frame when the wrap occurs.\n- Score should NOT be reset on wrap — the player keeps accumulating points.\n- The wrap check in `updateOnTrack()` must happen AFTER position update but BEFORE the return statement (it already does).\n- Make sure `wrapped: false` is also in the `updateFalling()` return so the game loop can always check `result.wrapped`.\n\n## Testing\n- `docker build -t teeter .` must succeed\n- Ball wraps to start of track when reaching the end\n- After wrapping, obstacles appear in NEW random positions (different from previous lap)\n- After wrapping, fresh coins are visible on the track in new positions\n- After wrapping, a new turtle powerup appears\n- Score persists across wraps (does not reset to 0)\n- Game-over and restart still work correctly\n- Existing coin collection, obstacle collision, and turtle powerup still work", + "subtasks": [], + "integration": null, + "verification": { + "buildCommand": "docker build -t teeter .", + "runCommand": "docker run -d -p 8080:8080 teeter", + "readySignal": "listening|ready|started|Configuration complete", + "appType": "web", + "port": 8080, + "checks": [ + "After the ball rolls to the end of the track and wraps back to the start, new obstacles (red boxes) appear in different random positions than the previous lap", + "After wrapping, fresh gold torus-shaped coins are visible on the track and can be collected to increment the score", + "After wrapping, a new green turtle powerup appears on the track", + "The score counter in the top-left persists and accumulates across wraps (does not reset to 0 when wrapping)", + "Existing red obstacles still cause game over when the ball collides with them", + "After a game-over and restart, the game still functions correctly with a fresh level layout" + ] + } +}