diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index c92603d..7e5390b 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -1949,6 +1949,14 @@ exact path an E press takes (latch the executing state, lock input, then tick and the cooldown starts), and the executing block runs before the ImGui gate, so headless tests drive actuators through it (demo-taxi `--test-furniture`). +`handleDoorPress(character, doorEntity)` is the same for doors — an E press +on a door entity: key-item unlock, then the `door_locked` events with a +same-press fall-through when the synchronous handlers unlock the door (F6 +event contract), then toggle open / scene-switch (no swing animation here — +`DoorSystem` consumes `toggleRequested` later in the frame). Also runs +before the ImGui gate, so headless tests drive doors through it (demo-taxi +`--test-switch` F6 leg). + ### DoorSystem & CellGrid doors `CellGridSystem::buildDoorEntities()` spawns one runtime door entity per @@ -2349,7 +2357,11 @@ section). Ephemeral doors (empty `doorId`) never touch the store. `keyItemId` and the player inventory contains the item, the door unlocks (keys are NOT consumed) and the press proceeds normally; otherwise the events `door_locked_` / `door_locked` (params: `door_id`, - `entity_id`) are emitted and nothing happens. Holding E on an unlocked + `entity_id`) are emitted. EventBus dispatch is synchronous, so when a + subscriber unlocks the door inside the handlers (the F6 event contract — + scene scripts answer with `door_unlock_`), the SAME press + continues and opens the door; only handlers that leave the lock on + require a second press. Holding E on an unlocked lockable door opens a small menu with "Close" and "Lock" (Lock requires the key when `keyItemId` is set). - **Lua** (`lua/LuaDoorApi.cpp`, example @@ -2872,8 +2884,14 @@ ecs.switch_scene(path, { position = ..., rotation = { w=1, x=0, y=0, z=0 } }) (`EditorApp::renderSceneSwitchCover`, drawn from the ImGui render listener, headless-safe) hides the scene for `SCENE_SWITCH_COVER_FRAMES` (45) frames after each switch, covering the characters' initial T-pose while animations - warm up. It is armed at the end of `performSceneSwitch` and counted down in - `frameRenderingQueued` after `processPendingSceneSwitch()`. + warm up. It is armed TWICE: once at `switchScene()` queue time (game mode), + so the frame that queued the switch already presents with the cover up — + the switch itself then runs synchronously at the top of the next + `frameRenderingQueued` and its teardown+load hitch hides behind the cover + instead of freezing the last scenery frame on screen — and again at the + end of `performSceneSwitch`, which keeps the post-load coverage duration + unchanged. The counter is counted down in `frameRenderingQueued` after + `processPendingSceneSwitch()`. The `switchScene` behavior tree node (name=scene path) queues the same switch from AI trees (SmartObject/GOAP) and actuator/player-action trees; it fires diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index acacb66..1b9364e 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -1394,6 +1394,17 @@ bool EditorApp::switchScene(const Ogre::String &scenePath, m_pendingSceneSwitchPath = scenePath; m_pendingSceneSwitchOptions = opts; m_sceneSwitchPending = true; + + /* Raise the loading cover NOW (game mode): the switch itself runs + * synchronously at the top of the next frame and stalls rendering + * for its whole teardown+load duration, so the last presented + * frame (empty scenery with the open door) would otherwise freeze + * on screen through the entire hitch. Arming at queue time makes + * the frame that queued the switch present with the cover already + * up; the re-arm at the end of performSceneSwitch keeps the full + * post-load coverage unchanged. */ + if (m_gameMode == GameMode::Game) + m_sceneSwitchCoverFrames = SCENE_SWITCH_COVER_FRAMES; return true; } diff --git a/src/features/editScene/GameFeatures202609.md b/src/features/editScene/GameFeatures202609.md index 476dd32..93297e7 100644 --- a/src/features/editScene/GameFeatures202609.md +++ b/src/features/editScene/GameFeatures202609.md @@ -626,8 +626,10 @@ editor-validated-unique `doorId` field on standalone doors (F2). F6 consumes `ecs.send_event("door_unlock_")` (or `ecs.door.unlock(doorId)`, a thin wrapper over `ecs.global.set("door." .. doorId .. ".locked", false)`) which clears - the locked key; the door does **not** open by itself — the player - presses E again. Because the state is a plain global variable, scripts + the locked key. Because the EventBus dispatches synchronously, the + same press that bumped into the lock continues and opens the door + once a handler accepted; only handlers that keep the lock require a + second press. Because the state is a plain global variable, scripts can also lock/unlock doors directly with `ecs.global.set()`. 4. If nothing unlocks it, the door stays shut; the prompt shows "E Locked". @@ -818,7 +820,10 @@ sub-millisecond while swings are real-time). - `DoorSystem` detects swing completion (`currentAngle == openAngle` while `isOpen && sceneSwitchPending`) and only then calls `EditorApp::switchScene(sceneSwitchPath, {targetEntityName: - sceneSwitchTarget})`. The existing loading cover hides the switch. + sceneSwitchTarget})`. The loading cover is armed at switchScene() queue + time, so the screen goes black the frame the door completes opening — + before the synchronous tear-down/load hitch can freeze the last scenery + frame on screen. - **Black occluder:** `buildDoorEntities()` (and the F2 standalone builder) additionally creates a black occluder entity childed to the door entity, for doors with a non-empty `sceneSwitchPath`. All of them get the same diff --git a/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_main.cpp b/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_main.cpp index c692f94..f38a1d6 100644 --- a/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_main.cpp +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_main.cpp @@ -3,6 +3,7 @@ #include "EditorApp.hpp" #include "ProjectConfig.hpp" #include "camera/EditorCamera.hpp" +#include "systems/ActuatorSystem.hpp" #include "systems/CharacterRegistry.hpp" #include "systems/DoorSystem.hpp" #include "systems/PlayerControllerSystem.hpp" @@ -427,23 +428,54 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { failReason = "F6 demo door not locked at start"; break; } - /* Emulate the ActuatorSystem E-press on a locked door; - * the inline scene script answers door_locked with - * door_unlock_. */ - EventBus::getInstance().send("door_locked", "door_id", - doorId); + /* E-press the locked door through the exact path + * ActuatorSystem takes (handleDoorPress): the + * door_locked events go out and the inline scene + * script answers door_locked with + * door_unlock_ inside the synchronous + * dispatch, so the SAME press unlocks the door and + * requests the open swing instead of needing a + * second press. */ + { + ActuatorSystem *actuators = + app->getActuatorSystem(); + if (!actuators) { + failed = true; + failReason = "no ActuatorSystem"; + break; + } + actuators->handleDoorPress( + app->getPlayerCharacterEntity(), + door); + if (DoorSystem::isDoorLockedById(doorId)) { + failed = true; + failReason = "F6 same press did not " + "unlock the door"; + break; + } + if (!door.get() + .toggleRequested) { + failed = true; + failReason = "F6 same press did not " + "request the open swing"; + break; + } + std::cout << "[test] F6 locked door: one E " + "press unlocked and opened it" + << std::endl; + } phase = 11; break; } case 11: { - /* F6: wait for the scene script to unlock the door - * (the frame timeout covers a missing handler). */ - if (DoorSystem::isDoorLockedById(doorId)) - break; + /* F6: DoorSystem consumed the same-press open request + * and the swing is under way (waiting here on the + * consumed flag covers any frame-listener ordering). */ flecs::entity door = findDoor(); if (!door.is_alive()) break; - door.get_mut().toggleRequested = true; + if (door.get().toggleRequested) + break; std::cout << "[test] F6 door unlocked via scene script, " "opening" << std::endl; @@ -761,8 +793,9 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { * lockable + locked by default, with a fixed gridUid so its global door * ID is stable, and the grid entity carries an inline scene script that * answers the "door_locked" event with "door_unlock_" (the door - * event contract; the door never opens by itself, the player presses E - * again). The door's locked and open/closed state lives in the global + * event contract; the dispatch is synchronous, so the same press + * continues and opens the door). The door's locked and open/closed + * state lives in the global * state store and survives the interior -> exterior -> interior scene * switches. * diff --git a/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp b/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp index 0896222..ffde39d 100644 --- a/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp +++ b/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp @@ -2,6 +2,7 @@ #include "EditorApp.hpp" #include "ProjectConfig.hpp" #include "camera/EditorCamera.hpp" +#include "systems/ActuatorSystem.hpp" #include "systems/CharacterRegistry.hpp" #include "systems/DoorSystem.hpp" #include "systems/EventBus.hpp" @@ -145,6 +146,25 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { return found; } + /* Resolves a named entity's live position (used for the arrival + * markers, whose placement is scene-specific). */ + bool entityPosition(const char *name, Ogre::Vector3 &out) + { + bool found = false; + app->getWorld() + ->query() + .each([&](flecs::entity, EntityNameComponent &n, + TransformComponent &t) { + if (n.name != name) + return; + out = t.node ? + t.node->_getDerivedPosition() : + t.position; + found = true; + }); + return found; + } + /* The F6 demo door entity (recreated on every scene load). */ flecs::entity findDoor() { @@ -227,7 +247,7 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { const TransformComponent &t = player.get(); Ogre::Vector3 pos = t.node ? t.node->_getDerivedPosition() : t.position; - if (pos.distance(expected) > tol) { + if (pos.distance(expected) > tol && !failed) { failed = true; failReason = "player not at arrival point: (" + Ogre::StringConverter::toString(pos.x) + @@ -240,10 +260,13 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { return true; } - /* The arrival markers sit at (0, 0, 24) facing -Z (toward the - * floor center); verify the camera took a position behind the - * character looking at the walking surface, and that the - * character's visual facing (local +Z) points at the center too. */ + /* The arrival markers face the floor centre (yaw 180): the + * camera must take a position behind the character looking at + * the walking surface, and the character's visual facing (local + * +Z) points at the centre too. Their positions are resolved + * from each scene's arrival_* marker entity (scene A's sits at + * (0, 0, 24), scene B's a few units in front of its return + * doorway), so the layouts can move freely. */ bool checkCameraFacesCenter(const Ogre::Vector3 &charPos) { flecs::entity player = app->getPlayerCharacterEntity(); @@ -264,8 +287,9 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { Ogre::Vector3 cp = camNode->_getDerivedPosition(); Ogre::Vector3 vd = camNode->_getDerivedOrientation() * Ogre::Vector3::NEGATIVE_UNIT_Z; - if (vd.z > -0.9f || cp.z < charPos.z + 1.0f || - charFacing.z > -0.9f) { + if ((vd.z > -0.9f || cp.z < charPos.z + 1.0f || + charFacing.z > -0.9f) && + !failed) { failed = true; failReason = "not facing the walking surface: camera pos (" + @@ -320,23 +344,54 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { failReason = "F6 demo door not locked at start"; break; } - /* Emulate the ActuatorSystem E-press on a locked door; - * the inline scene script answers door_locked with - * door_unlock_. */ - EventBus::getInstance().send("door_locked", "door_id", - doorId); + /* E-press the locked door through the exact path + * ActuatorSystem takes (handleDoorPress): the + * door_locked events go out and the inline scene + * script answers door_locked with + * door_unlock_ inside the synchronous + * dispatch, so the SAME press unlocks the door and + * requests the open swing instead of needing a + * second press. */ + { + ActuatorSystem *actuators = + app->getActuatorSystem(); + if (!actuators) { + failed = true; + failReason = "no ActuatorSystem"; + break; + } + actuators->handleDoorPress( + app->getPlayerCharacterEntity(), + door); + if (DoorSystem::isDoorLockedById(doorId)) { + failed = true; + failReason = "F6 same press did not " + "unlock the door"; + break; + } + if (!door.get() + .toggleRequested) { + failed = true; + failReason = "F6 same press did not " + "request the open swing"; + break; + } + std::cout << "[test] F6 locked door: one E " + "press unlocked and opened it" + << std::endl; + } phase = 11; break; } case 11: { - /* F6: wait for the scene script to unlock the door - * (the frame timeout covers a missing handler). */ - if (DoorSystem::isDoorLockedById(doorId)) - break; + /* F6: DoorSystem consumed the same-press open request + * and the swing is under way (waiting here on the + * consumed flag covers any frame-listener ordering). */ flecs::entity door = findDoor(); if (!door.is_alive()) break; - door.get_mut().toggleRequested = true; + if (door.get().toggleRequested) + break; std::cout << "[test] F6 door unlocked via scene script, " "opening" << std::endl; @@ -433,14 +488,20 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { << std::endl; } } - if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f), - 1.0f)) - break; - if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f, - 24.0f))) - break; - if (failed) - break; + { + Ogre::Vector3 arrival; + if (!entityPosition("arrival_b", + arrival)) { + failed = true; + failReason = + "arrival_b marker not found"; + break; + } + checkPlayerNear(arrival, 1.0f); + checkCameraFacesCenter(arrival); + if (failed) + break; + } std::cout << "[test] arrived in scene B at arrival_b, " "camera faces the walking surface" << std::endl; @@ -465,12 +526,20 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { case 2: if (!entityExists("interrior")) break; - if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f), - 1.0f)) - break; - if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f, - 24.0f))) - break; + { + Ogre::Vector3 arrival; + if (!entityPosition("arrival_a", + arrival)) { + failed = true; + failReason = + "arrival_a marker not found"; + break; + } + checkPlayerNear(arrival, 1.0f); + checkCameraFacesCenter(arrival); + if (failed) + break; + } /* F6: the door rebuilt with the scene must be snapped * back to the persisted open + unlocked state. */ { @@ -546,9 +615,10 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { * by default, with a fixed gridUid so its global door ID is stable, and * the grid entity carries an inline scene script that answers the * "door_locked" event with "door_unlock_" (the door event - * contract; the door never opens by itself, the player presses E again). - * The door's locked and open/closed state lives in the global state - * store and survives the A -> B -> A scene switches. + * contract; the dispatch is synchronous, so the same press continues + * and opens the door). The door's locked and open/closed state lives + * in the global state store and survives the A -> B -> A scene + * switches. * * F1 demo content: both scene transitions go through scene-switch doors. * Scene A's grid has the external exit doorway Z:0:0:15 configured as a diff --git a/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json index 02d2501..f642dca 100644 --- a/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json +++ b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json @@ -1495,7 +1495,7 @@ "uvMargin": 0.009999999776482582 }, "sceneScript": { - "inlineScript": "-- F6 demo: the internal doorway Z:0:0:8 of this grid is lockable\n-- and locked by default (see the grid's doorConfigs). The first time\n-- the player bumps into the locked door, this handler unlocks it through\n-- the door event contract; the door itself never opens on its own, the\n-- player presses E again after the unlock.\nlocal door_id = \"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8\"\n\necs.subscribe_event(\"door_locked\", function(event, params)\n if params and params.door_id == door_id then\n print(\"[demo] door_locked for \" .. door_id ..\n \" (locked=\" .. tostring(ecs.door.is_locked(door_id)) ..\n \"), unlocking\")\n ecs.send_event(\"door_unlock_\" .. door_id)\n print(\"[demo] door unlocked, locked=\" ..\n tostring(ecs.door.is_locked(door_id)))\n end\nend)\n", + "inlineScript": "-- F6 demo: the internal doorway Z:0:0:8 of this grid is lockable\n-- and locked by default (see the grid's doorConfigs). The first time\n-- the player bumps into the locked door, this handler unlocks it through\n-- the door event contract; the unlock dispatches synchronously, so the\n-- same E press continues and opens the door.\nlocal door_id = \"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8\"\n\necs.subscribe_event(\"door_locked\", function(event, params)\n if params and params.door_id == door_id then\n print(\"[demo] door_locked for \" .. door_id ..\n \" (locked=\" .. tostring(ecs.door.is_locked(door_id)) ..\n \"), unlocking\")\n ecs.send_event(\"door_unlock_\" .. door_id)\n print(\"[demo] door unlocked, locked=\" ..\n tostring(ecs.door.is_locked(door_id)))\n end\nend)\n", "scriptPath": "" }, "transform": { diff --git a/src/features/editScene/demos/demo-sokoban/demo_main.cpp b/src/features/editScene/demos/demo-sokoban/demo_main.cpp index 53d97c7..e68ce87 100644 --- a/src/features/editScene/demos/demo-sokoban/demo_main.cpp +++ b/src/features/editScene/demos/demo-sokoban/demo_main.cpp @@ -3,6 +3,7 @@ #include "EditorApp.hpp" #include "ProjectConfig.hpp" #include "camera/EditorCamera.hpp" +#include "systems/ActuatorSystem.hpp" #include "systems/CharacterRegistry.hpp" #include "systems/DoorSystem.hpp" #include "systems/PlayerControllerSystem.hpp" @@ -516,23 +517,54 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { failReason = "F6 demo door not locked at start"; break; } - /* Emulate the ActuatorSystem E-press on a locked door; - * the inline scene script answers door_locked with - * door_unlock_. */ - EventBus::getInstance().send("door_locked", "door_id", - doorId); + /* E-press the locked door through the exact path + * ActuatorSystem takes (handleDoorPress): the + * door_locked events go out and the inline scene + * script answers door_locked with + * door_unlock_ inside the synchronous + * dispatch, so the SAME press unlocks the door and + * requests the open swing instead of needing a + * second press. */ + { + ActuatorSystem *actuators = + app->getActuatorSystem(); + if (!actuators) { + failed = true; + failReason = "no ActuatorSystem"; + break; + } + actuators->handleDoorPress( + app->getPlayerCharacterEntity(), + door); + if (DoorSystem::isDoorLockedById(doorId)) { + failed = true; + failReason = "F6 same press did not " + "unlock the door"; + break; + } + if (!door.get() + .toggleRequested) { + failed = true; + failReason = "F6 same press did not " + "request the open swing"; + break; + } + std::cout << "[test] F6 locked door: one E " + "press unlocked and opened it" + << std::endl; + } phase = 11; break; } case 11: { - /* F6: wait for the scene script to unlock the door - * (the frame timeout covers a missing handler). */ - if (DoorSystem::isDoorLockedById(doorId)) - break; + /* F6: DoorSystem consumed the same-press open request + * and the swing is under way (waiting here on the + * consumed flag covers any frame-listener ordering). */ flecs::entity door = findDoor(); if (!door.is_alive()) break; - door.get_mut().toggleRequested = true; + if (door.get().toggleRequested) + break; std::cout << "[test] F6 door unlocked via scene script, " "opening" << std::endl; @@ -2548,8 +2580,9 @@ struct SokobanResetTestListener : public Ogre::FrameListener { * lockable + locked by default, with a fixed gridUid so its global door * ID is stable, and the grid entity carries an inline scene script that * answers the "door_locked" event with "door_unlock_" (the door - * event contract; the door never opens by itself, the player presses E - * again). The door's locked and open/closed state lives in the global + * event contract; the dispatch is synchronous, so the same press + * continues and opens the door). The door's locked and open/closed + * state lives in the global * state store and survives the interior -> exterior -> interior scene * switches. * diff --git a/src/features/editScene/demos/demo-sokoban/demo_scene_interior.json b/src/features/editScene/demos/demo-sokoban/demo_scene_interior.json index 3621489..4010b5a 100644 --- a/src/features/editScene/demos/demo-sokoban/demo_scene_interior.json +++ b/src/features/editScene/demos/demo-sokoban/demo_scene_interior.json @@ -1495,7 +1495,7 @@ "uvMargin": 0.009999999776482582 }, "sceneScript": { - "inlineScript": "-- F6 demo: the internal doorway Z:0:0:8 of this grid is lockable\n-- and locked by default (see the grid's doorConfigs). The first time\n-- the player bumps into the locked door, this handler unlocks it through\n-- the door event contract; the door itself never opens on its own, the\n-- player presses E again after the unlock.\nlocal door_id = \"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8\"\n\necs.subscribe_event(\"door_locked\", function(event, params)\n if params and params.door_id == door_id then\n print(\"[demo] door_locked for \" .. door_id ..\n \" (locked=\" .. tostring(ecs.door.is_locked(door_id)) ..\n \"), unlocking\")\n ecs.send_event(\"door_unlock_\" .. door_id)\n print(\"[demo] door unlocked, locked=\" ..\n tostring(ecs.door.is_locked(door_id)))\n end\nend)\n", + "inlineScript": "-- F6 demo: the internal doorway Z:0:0:8 of this grid is lockable\n-- and locked by default (see the grid's doorConfigs). The first time\n-- the player bumps into the locked door, this handler unlocks it through\n-- the door event contract; the unlock dispatches synchronously, so the\n-- same E press continues and opens the door.\nlocal door_id = \"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8\"\n\necs.subscribe_event(\"door_locked\", function(event, params)\n if params and params.door_id == door_id then\n print(\"[demo] door_locked for \" .. door_id ..\n \" (locked=\" .. tostring(ecs.door.is_locked(door_id)) ..\n \"), unlocking\")\n ecs.send_event(\"door_unlock_\" .. door_id)\n print(\"[demo] door unlocked, locked=\" ..\n tostring(ecs.door.is_locked(door_id)))\n end\nend)\n", "scriptPath": "" }, "transform": { diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp index 614bf6c..12c6499 100644 --- a/src/features/editScene/demos/demo-taxi/demo_main.cpp +++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp @@ -2514,23 +2514,54 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { failReason = "F6 demo door not locked at start"; break; } - /* Emulate the ActuatorSystem E-press on a locked door; - * the inline scene script answers door_locked with - * door_unlock_. */ - EventBus::getInstance().send("door_locked", "door_id", - doorId); + /* E-press the locked door through the exact path + * ActuatorSystem takes (handleDoorPress): the + * door_locked events go out and the inline scene + * script answers door_locked with + * door_unlock_ inside the synchronous + * dispatch, so the SAME press unlocks the door and + * requests the open swing instead of needing a + * second press. */ + { + ActuatorSystem *actuators = + app->getActuatorSystem(); + if (!actuators) { + failed = true; + failReason = "no ActuatorSystem"; + break; + } + actuators->handleDoorPress( + app->getPlayerCharacterEntity(), + door); + if (DoorSystem::isDoorLockedById(doorId)) { + failed = true; + failReason = "F6 same press did not " + "unlock the door"; + break; + } + if (!door.get() + .toggleRequested) { + failed = true; + failReason = "F6 same press did not " + "request the open swing"; + break; + } + std::cout << "[test] F6 locked door: one E " + "press unlocked and opened it" + << std::endl; + } phase = 11; break; } case 11: { - /* F6: wait for the scene script to unlock the door - * (the frame timeout covers a missing handler). */ - if (DoorSystem::isDoorLockedById(doorId)) - break; + /* F6: DoorSystem consumed the same-press open request + * and the swing is under way (waiting here on the + * consumed flag covers any frame-listener ordering). */ flecs::entity door = findDoor(); if (!door.is_alive()) break; - door.get_mut().toggleRequested = true; + if (door.get().toggleRequested) + break; std::cout << "[test] F6 door unlocked via scene script, " "opening" << std::endl; @@ -6922,10 +6953,10 @@ struct DriveBenchmarkListener : public Ogre::FrameListener, * lockable + locked by default, with a fixed gridUid so its global door * ID is stable, and the grid entity carries an inline scene script that * answers the "door_locked" event with "door_unlock_" (the door - * event contract; the door never opens by itself, the player presses E - * again). The door's locked and open/closed state lives in the global - * state store and survives the interior -> exterior -> interior scene - * switches. + * event contract; the dispatch is synchronous, so the same press + * continues and opens the door). The door's locked and open/closed + * state lives in the global state store and survives the interior -> + * exterior -> interior scene switches. * * F1 demo content: both scene transitions go through scene-switch doors. * The interior grid has the external exit doorway Z:0:0:15 configured as diff --git a/src/features/editScene/demos/demo-taxi/demo_scene_interior.json b/src/features/editScene/demos/demo-taxi/demo_scene_interior.json index 75bc6ac..6bb612a 100644 --- a/src/features/editScene/demos/demo-taxi/demo_scene_interior.json +++ b/src/features/editScene/demos/demo-taxi/demo_scene_interior.json @@ -1551,7 +1551,7 @@ "uvMargin": 0.009999999776482582 }, "sceneScript": { - "inlineScript": "-- F6 demo: the internal doorway Z:0:0:8 of this grid is lockable\n-- and locked by default (see the grid's doorConfigs). The first time\n-- the player bumps into the locked door, this handler unlocks it through\n-- the door event contract; the door itself never opens on its own, the\n-- player presses E again after the unlock.\nlocal door_id = \"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8\"\n\necs.subscribe_event(\"door_locked\", function(event, params)\n if params and params.door_id == door_id then\n print(\"[demo] door_locked for \" .. door_id ..\n \" (locked=\" .. tostring(ecs.door.is_locked(door_id)) ..\n \"), unlocking\")\n ecs.send_event(\"door_unlock_\" .. door_id)\n print(\"[demo] door unlocked, locked=\" ..\n tostring(ecs.door.is_locked(door_id)))\n end\nend)\n", + "inlineScript": "-- F6 demo: the internal doorway Z:0:0:8 of this grid is lockable\n-- and locked by default (see the grid's doorConfigs). The first time\n-- the player bumps into the locked door, this handler unlocks it through\n-- the door event contract; the unlock dispatches synchronously, so the\n-- same E press continues and opens the door.\nlocal door_id = \"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8\"\n\necs.subscribe_event(\"door_locked\", function(event, params)\n if params and params.door_id == door_id then\n print(\"[demo] door_locked for \" .. door_id ..\n \" (locked=\" .. tostring(ecs.door.is_locked(door_id)) ..\n \"), unlocking\")\n ecs.send_event(\"door_unlock_\" .. door_id)\n print(\"[demo] door unlocked, locked=\" ..\n tostring(ecs.door.is_locked(door_id)))\n end\nend)\n", "scriptPath": "" }, "transform": { diff --git a/src/features/editScene/lua-examples/door_lock_example.lua b/src/features/editScene/lua-examples/door_lock_example.lua index 1608978..613d071 100644 --- a/src/features/editScene/lua-examples/door_lock_example.lua +++ b/src/features/editScene/lua-examples/door_lock_example.lua @@ -17,7 +17,8 @@ -- door_unlocked_ notification after a door was unlocked. -- door_unlocked generic notification; params: door_id. -- --- The door never opens by itself on unlock - the player presses E again. +-- A scripted unlock dispatches synchronously, so the same E press that +-- bumped into the lock continues and opens the door. -- A door configured with keyItemId is unlocked by E when the player has -- the item in the inventory (keys are NOT consumed); holding E on a -- lockable door offers Close/Lock in the action menu. diff --git a/src/features/editScene/systems/ActuatorSystem.cpp b/src/features/editScene/systems/ActuatorSystem.cpp index 5e9e11e..563f720 100644 --- a/src/features/editScene/systems/ActuatorSystem.cpp +++ b/src/features/editScene/systems/ActuatorSystem.cpp @@ -127,6 +127,68 @@ void ActuatorSystem::executeAction(flecs::entity character, "[ActuatorSystem] Executing action: " + actionName); } +void ActuatorSystem::handleDoorPress(flecs::entity playerCharacter, + flecs::entity doorEntity) +{ + if (!doorEntity.is_alive() || !doorEntity.has()) + return; + + auto &door = doorEntity.get_mut(); + + /* Locked doors (F6): the configured key item unlocks (keys are + * not consumed); otherwise the door_locked events are emitted. */ + bool locked = DoorSystem::isDoorLocked(door); + if (locked && !door.keyItemId.empty() && + playerCharacter.is_alive() && + playerCharacter.has() && + playerCharacter.get() + .hasItem(door.keyItemId)) { + DoorSystem::setDoorLocked(door.doorId, false); + locked = false; + } + if (locked) { + editScene::EventParams params; + params.setString("door_id", door.doorId); + params.setEntityId("entity_id", doorEntity.id()); + EventBus &bus = EventBus::getInstance(); + bus.send("door_locked_" + door.doorId, params); + bus.send("door_locked", params); + /* EventBus dispatches synchronously, so re-check the + * lock state: if a door_locked subscriber unlocked the + * door during the dispatch (the F6 event contract — + * scene scripts answer with door_unlock_), the + * same press continues below instead of requiring a + * second press to open. */ + locked = DoorSystem::isDoorLocked(door); + } + if (locked) + return; + + if (!door.sceneSwitchPath.empty()) { + /* F1: the door swings open first; DoorSystem fires the + * switch at full opening. An already-open door switches + * immediately. */ + if (door.isOpen && door.currentAngle == door.openAngle) { + SceneSwitchOptions opts; + opts.targetEntityName = door.sceneSwitchTarget; + m_editorApp->switchScene(door.sceneSwitchPath, opts); + } else { + door.toggleRequested = true; + door.sceneSwitchPending = true; + } + } else { + door.toggleRequested = true; + if (doorEntity.has()) { + auto &actuator = doorEntity.get(); + if (!actuator.actionNames.empty() && + !actuator.actionNames[0].empty()) { + executeAction(playerCharacter, doorEntity, + actuator.actionNames[0]); + } + } + } +} + void ActuatorSystem::executeItemAction(flecs::entity character, flecs::entity itemEntity, const Ogre::String &actionName) @@ -564,69 +626,8 @@ void ActuatorSystem::update(float deltaTime) auto &door = targetEntity.get_mut(); if (input.ePressed) { - /* Locked doors (F6): the configured key - * item unlocks (keys are not consumed); - * otherwise only the door_locked events - * are emitted. */ - bool locked = DoorSystem::isDoorLocked(door); - if (locked && !door.keyItemId.empty() && - playerCharacter.is_alive() && - playerCharacter - .has() && - playerCharacter - .get() - .hasItem(door.keyItemId)) { - DoorSystem::setDoorLocked( - door.doorId, false); - locked = false; - } - if (locked) { - editScene::EventParams params; - params.setString("door_id", - door.doorId); - params.setEntityId( - "entity_id", - targetEntity.id()); - EventBus &bus = - EventBus::getInstance(); - bus.send("door_locked_" + - door.doorId, - params); - bus.send("door_locked", params); - } else if (!door.sceneSwitchPath.empty()) { - /* F1: the door swings open - * first; DoorSystem fires the - * switch at full opening. An - * already-open door switches - * immediately. */ - if (door.isOpen && - door.currentAngle == - door.openAngle) { - SceneSwitchOptions opts; - opts.targetEntityName = - door.sceneSwitchTarget; - m_editorApp->switchScene( - door.sceneSwitchPath, - opts); - } else { - door.toggleRequested = - true; - door.sceneSwitchPending = - true; - } - } else { - door.toggleRequested = true; - if (!actuator.actionNames - .empty() && - !actuator.actionNames[0] - .empty()) { - executeAction( - playerCharacter, - targetEntity, - actuator.actionNames - [0]); - } - } + handleDoorPress(playerCharacter, + targetEntity); m_eHoldTime = 0.0f; } else if (door.lockable && !door.doorId.empty() && !DoorSystem::isDoorLocked(door) && diff --git a/src/features/editScene/systems/ActuatorSystem.hpp b/src/features/editScene/systems/ActuatorSystem.hpp index 8892eba..44891ba 100644 --- a/src/features/editScene/systems/ActuatorSystem.hpp +++ b/src/features/editScene/systems/ActuatorSystem.hpp @@ -64,6 +64,17 @@ public: flecs::entity actuatorEntity, const Ogre::String &actionName); + /** The exact action of an E press on a door entity (the door + * branch of the E press handling): a held keyItem unlocks, else + * the door_locked events are emitted and — because the EventBus + * dispatches subscribers synchronously — the F6 scripted unlock + * (door_unlock_ inside a door_locked handler) lets the + * SAME press continue to toggle/scene-switch instead of + * requiring a second press. Runs before any ImGui use, so + * headless tests can drive doors through this. */ + void handleDoorPress(flecs::entity playerCharacter, + flecs::entity doorEntity); + private: struct ScreenActuator { flecs::entity entity;