Place the player, not the XR origin, at spawn, menu and save #16

Open
hiccup444 wants to merge 2 commits from hiccup444/da-game-hiccup:place-player-not-origin into main
First-time contributor

Summary

Every placement wrote the marker straight into the XROrigin3D. That covers the menu anchor, the spawn point and the save restore. With the default OpenXR reference space (Stage, xr/openxr/reference_space = 1) the origin is the centre of the player's play area, not the player. So the player landed offset from every marker by wherever they stood in their room, and faced whichever way they happened to face:

  • Main menu. The panel is fixed in the level. Standing 1 m left of the play-area centre put the player 1 m left of the anchor. Facing a side wall put the menu off to the side or behind them.
  • New Game, Sandbox, Continue. The same offset and facing applied at the spawn point, and next to level geometry the offset could put the head inside a wall.
  • Saves. Saves stored the origin too. Continue re-applied the room offset from save time on top of where the player stands now, so saving at one edge of the room and loading from the other shifted the player by up to the play-area width.

None of this showed in flat mode or in the headless suites, because the FlatCamera sits fixed at (0, 1.6, 0) over the origin.

Changes

  • PlayerRig.place_player(target) moves the origin so that the head, projected to the floor, and its facing land on the target. This is the same idea as XRToolsPlayerBody.teleport(), worked from the camera because the body is off, and stale, while anchored.
  • PlayerRig.player_floor_transform() returns the player's floor point and facing in world space.
  • _head_floor_local() takes the camera position flattened to the floor and the yaw of the camera's forward. When the player looks steeply up or down (forward.y beyond 0.75) the facing comes from the head's up vector, which is the rule XR Tools uses for the body. In flat mode it is identity, so desktop placement is unchanged.
  • place_at(marker) and place_at_transform(t) keep their names and signatures and now go through place_player. Every caller, including those on open branches, gets the new behaviour. Their doc comments now describe the new meaning.
  • Boot menu. Game._ready places the player at the menu anchor before the first frame. At that point the headset has not reported a head pose, and the XRCamera3D still sits at its default pose over the origin, so that one placement could only put the play-area centre on the anchor. A placement made in XR without a tracked head pose is now kept, and the rig makes it again on the first frame the head is tracked (_redo_untracked_placement(), called from _process). Placements made with tracking live are not repeated. A placement made while the head pose is lost, for example with the headset off, is redone the same way when tracking comes back.
  • Game._save_current_run() stores player_floor_transform() under a new key, player_transform. continue_game() reads player_transform and falls back to the old rig_transform through the same path.
  • The _place_pause_panel() fallback for looking straight up or down took its facing from the origin, which is the play area's forward. It now uses the player's facing.
  • Debug bridge: place_rig still sets the origin directly, since its tool description, the snapshot and the e2e are all about the origin. The README no longer calls it the equivalent of place_at; the two agree only in flat mode.

Old saves. The save format stays at 1. A run.json from before this change holds the origin under rig_transform. It loads once with that origin read as the player's floor point. The player lands off by at most how far from the play-area centre they stood when saving, and faces the play area's forward instead of the way they faced. The next save writes player_transform.

Tests

  • tests/rig_motion_check.gd, the clean-process rig unit check. The fixture's origin and both cameras are now in the tree. The existing velocity checks are unchanged. New checks run once per camera path: the XR camera with flat mode off, and the FlatCamera with flat mode on. The other camera sits at rest, so reading the wrong one fails.
    • Head at local (0.8, 1.6, 0.5), turned 90 degrees, then place_at(marker):
      • the camera's world XZ is on the marker's XZ within 1 mm;
      • the flattened forward equals the marker's -Z;
      • player_floor_transform() returns the marker;
      • the origin moved more than 0.5 m off the marker, so the check is not vacuous.
    • A 24-pose sweep covers yaw 90, -135, 180 (the atan2 seam) and 10, by pitch 0, -30, -80, -90, 80 and 90. For each pose the camera's world basis must equal the marker's basis pitched by the same angle. This independently checks the yaw formula and both sides of the steep-look rule. The XZ and read-back checks run on every pose too.
    • Two rolled steep looks (pitch -80 and 80 with 20 degrees of roll). Rolling swings the head's up vector but not its forward, so these pin that past the 0.75 threshold the facing follows the up vector, as in XR Tools.
    • With the FlatCamera at rest, the origin lands on the marker exactly as before.
    • Boot: with the XR camera at its default pose, place_at lands the origin on the marker. A stand-in head tracker registered with XRServer then reports an off-centre, turned head. On the rig's next frame the head is over the marker, facing its -Z, and a later head pose does not move the origin again.
    • Both camera paths ran headless. With no head tracker, the XRCamera3D keeps a pose set by hand. A stand-in XRPositionalTracker named head drives it the way OpenXR does. Checked on 4.6.3 with OpenXR off; on CI OpenXR fails to start, so there is no real tracker there either.
  • tests/save_check.gd:
    • The FlatCamera is moved off-centre and turned, so player and origin differ.
    • The round-trip now compares player_floor_transform() after Continue.
    • New check: the save holds player_transform at the player's floor point, and no rig_transform.
    • New check: a save with only rig_transform still loads.
  • tests/pause_check.gd: new check. The FlatCamera is turned inside the play space and pitched to -89 degrees, so its flattened forward falls under the fallback cutoff. The pause panel must open 1.3 m out along the player's facing, which differs from the origin's -Z here.
  • Mutation runs, not committed. Each one fails the checks named:
    • flipping the atan2 signs: the facing checks on every pose, and the boot check;
    • removing the looking-down branch of the steep rule: the pitch -90 poses;
    • removing the looking-up branch: the pitch 90 poses;
    • swapping the steep rule's signs: every pitch -80, -90, 80 and 90 pose;
    • raising the steep threshold to 0.99, or replacing the rule with a "forward too short" fallback: the rolled looks;
    • never redoing an untracked placement: the boot check; redoing it every frame: the only-once check;
    • saving the origin again, the old behaviour: four save_check checks;
    • putting back the origin's -Z in the pause fallback: the new pause_check check.
  • Also run against the real game scene with flat mode off and a stand-in head tracker, not committed: the boot menu placement is redone on the rig's own frame, and the main menu panel ends up 2.0 m straight ahead of the head.
  • Full suite list from CI: smoke_menu, arms_check, addon_check, save_check, rig_motion_check, pause_check and input_check all PASS. The local run was on Windows with --xr-mode off; CI covers Linux.
    • Error output matches main except for three more known harness lines: one in save_check from the extra Continue in the old-save check, and two in pause_check from the extra pause and resume. Each writes body.enabled on the harness's Nil body.

Corrections to the review

  • The review offered bumping the save format so old saves "read as stale". In this code that does not give a clean stale state. SaveGame.load_state rejects the mismatch, but has_state only checks that the file exists. The menu would keep offering Continue, and pressing it would do nothing. That is why this PR reads the old key instead.
  • The review's proposed fix works from the camera's pose, but the boot menu placement runs in Game._ready, before the headset has reported one. On its own the fix therefore left the first main menu after launch, the review's first symptom, placed as before. The boot redo above covers it.

Left for in-headset checking

  • Stand off-centre facing a side wall and choose Return to Menu. The panel should be straight ahead at the same distance every time.
  • Launch the game with the headset on, standing off-centre and turned. The first main menu should also be straight ahead. The redo uses the first tracked head pose, so if the headset sits somewhere else when tracking starts (on a desk, say), that first menu is placed from there instead.
  • New Game and Sandbox from off-centre and turned: you land on the spawn point facing the level's forward, with your head clear of nearby walls.
  • Save at one edge of the play area, walk to the other edge, then Continue. You should come back where you saved, facing the same way.
  • An old save loads once near the saved spot, off by at most the room offset and facing the play area's forward. The next save is exact.
  • Pause while looking straight down or up: the panel opens in the direction your body faces, not the play area's forward.

Overlap with open PRs

  • #14 changes the same file. It adds body_floor_transform() and arms anchoring after _process in player_rig.gd, and two calls at the top of _process. This PR's functions sit next to place_at, its one _process line goes after update_arms, and there are no shared names. The branch merges cleanly with #14 as it stands, and the merged tree passes the CI suites plus #14's anchor_check and skeleton_check.
    • body_floor_transform() (body position, body yaw) and player_floor_transform() (head floor point, head yaw with the steep-look rule) are related helpers, worth unifying once both land.
    • _anchor_arms_to_floor() computes the camera's XZ and yaw itself. The steep-look shoulder yaw agreed on #14 is the same rule _head_floor_local() uses.
  • #11 also changes game.gd and player_rig.gd, but its hunks do not touch the lines changed here. It merges cleanly, and the merged tree passes the CI suites plus its traversal_check and skeleton_check. Its traversal placements (place_at(_region.entry_point()), place_at(_level.traversal_anchor()) and the abort back to spawn) pick up head placement automatically.
    • tests/traversal_check.gd compares rig.origin with the anchor. In flat mode that equals the player's floor point, so it still passes, but player_floor_transform() is the better read there.

Sibling PRs from the same review: #15, #17, #18 and #19. Their source changes merge cleanly with each other. Several of them add checks at the same spot in tests/pause_check.gd (and two in tests/save_check.gd), so whichever merges second may need a small rebase that keeps both blocks.

## Summary Every placement wrote the marker straight into the `XROrigin3D`. That covers the menu anchor, the spawn point and the save restore. With the default OpenXR reference space (Stage, `xr/openxr/reference_space` = 1) the origin is the centre of the player's play area, not the player. So the player landed offset from every marker by wherever they stood in their room, and faced whichever way they happened to face: - **Main menu.** The panel is fixed in the level. Standing 1 m left of the play-area centre put the player 1 m left of the anchor. Facing a side wall put the menu off to the side or behind them. - **New Game, Sandbox, Continue.** The same offset and facing applied at the spawn point, and next to level geometry the offset could put the head inside a wall. - **Saves.** Saves stored the origin too. Continue re-applied the room offset from save time on top of where the player stands now, so saving at one edge of the room and loading from the other shifted the player by up to the play-area width. None of this showed in flat mode or in the headless suites, because the FlatCamera sits fixed at (0, 1.6, 0) over the origin. ## Changes - `PlayerRig.place_player(target)` moves the origin so that the head, projected to the floor, and its facing land on the target. This is the same idea as `XRToolsPlayerBody.teleport()`, worked from the camera because the body is off, and stale, while anchored. - `PlayerRig.player_floor_transform()` returns the player's floor point and facing in world space. - `_head_floor_local()` takes the camera position flattened to the floor and the yaw of the camera's forward. When the player looks steeply up or down (forward.y beyond 0.75) the facing comes from the head's up vector, which is the rule XR Tools uses for the body. In flat mode it is identity, so desktop placement is unchanged. - `place_at(marker)` and `place_at_transform(t)` keep their names and signatures and now go through `place_player`. Every caller, including those on open branches, gets the new behaviour. Their doc comments now describe the new meaning. - **Boot menu.** `Game._ready` places the player at the menu anchor before the first frame. At that point the headset has not reported a head pose, and the XRCamera3D still sits at its default pose over the origin, so that one placement could only put the play-area centre on the anchor. A placement made in XR without a tracked head pose is now kept, and the rig makes it again on the first frame the head is tracked (`_redo_untracked_placement()`, called from `_process`). Placements made with tracking live are not repeated. A placement made while the head pose is lost, for example with the headset off, is redone the same way when tracking comes back. - `Game._save_current_run()` stores `player_floor_transform()` under a new key, `player_transform`. `continue_game()` reads `player_transform` and falls back to the old `rig_transform` through the same path. - The `_place_pause_panel()` fallback for looking straight up or down took its facing from the origin, which is the play area's forward. It now uses the player's facing. - Debug bridge: `place_rig` still sets the origin directly, since its tool description, the snapshot and the e2e are all about the origin. The README no longer calls it the equivalent of `place_at`; the two agree only in flat mode. **Old saves.** The save format stays at 1. A `run.json` from before this change holds the origin under `rig_transform`. It loads once with that origin read as the player's floor point. The player lands off by at most how far from the play-area centre they stood when saving, and faces the play area's forward instead of the way they faced. The next save writes `player_transform`. ## Tests - `tests/rig_motion_check.gd`, the clean-process rig unit check. The fixture's origin and both cameras are now in the tree. The existing velocity checks are unchanged. New checks run once per camera path: the XR camera with flat mode off, and the FlatCamera with flat mode on. The other camera sits at rest, so reading the wrong one fails. - Head at local (0.8, 1.6, 0.5), turned 90 degrees, then `place_at(marker)`: - the camera's world XZ is on the marker's XZ within 1 mm; - the flattened forward equals the marker's -Z; - `player_floor_transform()` returns the marker; - the origin moved more than 0.5 m off the marker, so the check is not vacuous. - A 24-pose sweep covers yaw 90, -135, 180 (the atan2 seam) and 10, by pitch 0, -30, -80, -90, 80 and 90. For each pose the camera's world basis must equal the marker's basis pitched by the same angle. This independently checks the yaw formula and both sides of the steep-look rule. The XZ and read-back checks run on every pose too. - Two rolled steep looks (pitch -80 and 80 with 20 degrees of roll). Rolling swings the head's up vector but not its forward, so these pin that past the 0.75 threshold the facing follows the up vector, as in XR Tools. - With the FlatCamera at rest, the origin lands on the marker exactly as before. - Boot: with the XR camera at its default pose, `place_at` lands the origin on the marker. A stand-in head tracker registered with `XRServer` then reports an off-centre, turned head. On the rig's next frame the head is over the marker, facing its -Z, and a later head pose does not move the origin again. - Both camera paths ran headless. With no head tracker, the XRCamera3D keeps a pose set by hand. A stand-in `XRPositionalTracker` named `head` drives it the way OpenXR does. Checked on 4.6.3 with OpenXR off; on CI OpenXR fails to start, so there is no real tracker there either. - `tests/save_check.gd`: - The FlatCamera is moved off-centre and turned, so player and origin differ. - The round-trip now compares `player_floor_transform()` after Continue. - New check: the save holds `player_transform` at the player's floor point, and no `rig_transform`. - New check: a save with only `rig_transform` still loads. - `tests/pause_check.gd`: new check. The FlatCamera is turned inside the play space and pitched to -89 degrees, so its flattened forward falls under the fallback cutoff. The pause panel must open 1.3 m out along the player's facing, which differs from the origin's -Z here. - Mutation runs, not committed. Each one fails the checks named: - flipping the atan2 signs: the facing checks on every pose, and the boot check; - removing the looking-down branch of the steep rule: the pitch -90 poses; - removing the looking-up branch: the pitch 90 poses; - swapping the steep rule's signs: every pitch -80, -90, 80 and 90 pose; - raising the steep threshold to 0.99, or replacing the rule with a "forward too short" fallback: the rolled looks; - never redoing an untracked placement: the boot check; redoing it every frame: the only-once check; - saving the origin again, the old behaviour: four save_check checks; - putting back the origin's -Z in the pause fallback: the new pause_check check. - Also run against the real game scene with flat mode off and a stand-in head tracker, not committed: the boot menu placement is redone on the rig's own frame, and the main menu panel ends up 2.0 m straight ahead of the head. - Full suite list from CI: smoke_menu, arms_check, addon_check, save_check, rig_motion_check, pause_check and input_check all PASS. The local run was on Windows with `--xr-mode off`; CI covers Linux. - Error output matches main except for three more known harness lines: one in save_check from the extra Continue in the old-save check, and two in pause_check from the extra pause and resume. Each writes `body.enabled` on the harness's Nil body. ## Corrections to the review - The review offered bumping the save format so old saves "read as stale". In this code that does not give a clean stale state. `SaveGame.load_state` rejects the mismatch, but `has_state` only checks that the file exists. The menu would keep offering Continue, and pressing it would do nothing. That is why this PR reads the old key instead. - The review's proposed fix works from the camera's pose, but the boot menu placement runs in `Game._ready`, before the headset has reported one. On its own the fix therefore left the first main menu after launch, the review's first symptom, placed as before. The boot redo above covers it. ## Left for in-headset checking - Stand off-centre facing a side wall and choose Return to Menu. The panel should be straight ahead at the same distance every time. - Launch the game with the headset on, standing off-centre and turned. The first main menu should also be straight ahead. The redo uses the first tracked head pose, so if the headset sits somewhere else when tracking starts (on a desk, say), that first menu is placed from there instead. - New Game and Sandbox from off-centre and turned: you land on the spawn point facing the level's forward, with your head clear of nearby walls. - Save at one edge of the play area, walk to the other edge, then Continue. You should come back where you saved, facing the same way. - An old save loads once near the saved spot, off by at most the room offset and facing the play area's forward. The next save is exact. - Pause while looking straight down or up: the panel opens in the direction your body faces, not the play area's forward. ## Overlap with open PRs - **#14** changes the same file. It adds `body_floor_transform()` and arms anchoring after `_process` in `player_rig.gd`, and two calls at the top of `_process`. This PR's functions sit next to `place_at`, its one `_process` line goes after `update_arms`, and there are no shared names. The branch merges cleanly with #14 as it stands, and the merged tree passes the CI suites plus #14's anchor_check and skeleton_check. - `body_floor_transform()` (body position, body yaw) and `player_floor_transform()` (head floor point, head yaw with the steep-look rule) are related helpers, worth unifying once both land. - `_anchor_arms_to_floor()` computes the camera's XZ and yaw itself. The steep-look shoulder yaw agreed on #14 is the same rule `_head_floor_local()` uses. - **#11** also changes `game.gd` and `player_rig.gd`, but its hunks do not touch the lines changed here. It merges cleanly, and the merged tree passes the CI suites plus its traversal_check and skeleton_check. Its traversal placements (`place_at(_region.entry_point())`, `place_at(_level.traversal_anchor())` and the abort back to spawn) pick up head placement automatically. - `tests/traversal_check.gd` compares `rig.origin` with the anchor. In flat mode that equals the player's floor point, so it still passes, but `player_floor_transform()` is the better read there. Sibling PRs from the same review: #15, #17, #18 and #19. Their source changes merge cleanly with each other. Several of them add checks at the same spot in `tests/pause_check.gd` (and two in `tests/save_check.gd`), so whichever merges second may need a small rebase that keeps both blocks.
Spawn, menu and save placement wrote the marker straight into the
XROrigin3D. With the default Stage reference space the origin is the
centre of the play area, so the player landed offset from every marker
by where they stood in their room, facing whichever way they faced,
and saves stored that origin as well.

PlayerRig.place_player now moves the origin so that the head, projected
to the floor, and its facing land on the target. place_at and
place_at_transform keep their names and go through it, so callers and
open branches keep working. Saves store player_floor_transform() under
a new player_transform key. A save that only has the old rig_transform
still loads through the same path, once off by at most where the player
stood when saving and facing the play area's forward, so the save
format stays at 1. The pause panel's fallback for looking straight up
or down takes its facing from the player instead of the origin.

rig_motion_check poses each camera off-centre and turned, including
steep and rolled looks up and down, and checks that the head lands on
the marker. save_check round-trips the player's floor transform with
the head off-centre and loads an old rig_transform save. pause_check
covers the panel fallback when looking straight down.
player: redo a placement made before the head is tracked
Some checks failed
tests / test (pull_request) Failing after 58s
d329722879
Game places the player at the menu anchor in _ready, before OpenXR has
reported a head pose. The XRCamera3D then still sits at its default
pose over the origin, so the boot menu was placed from the play-area
centre, and a player standing off-centre or facing a side wall found
the panel off to the side or behind them, as before the head placement
change.

A placement made in XR without a tracked head pose is now kept, and
the rig makes it again on the first frame the head is tracked.
Placements made with tracking live are not repeated.

rig_motion_check registers a stand-in head tracker to cover it.
Some checks failed
tests / test (pull_request) Failing after 58s
This pull request has changes conflicting with the target branch.
  • src/player/player_rig.gd
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u place-player-not-origin:hiccup444-place-player-not-origin
git switch hiccup444-place-player-not-origin

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff hiccup444-place-player-not-origin
git switch hiccup444-place-player-not-origin
git rebase main
git switch main
git merge --ff-only hiccup444-place-player-not-origin
git switch hiccup444-place-player-not-origin
git rebase main
git switch main
git merge --no-ff hiccup444-place-player-not-origin
git switch main
git merge --squash hiccup444-place-player-not-origin
git switch main
git merge --ff-only hiccup444-place-player-not-origin
git switch main
git merge hiccup444-place-player-not-origin
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
dead-air/engine!16
No description provided.