diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 02:10:02 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 02:10:02 +0800 |
| commit | dd0907573e13981035a5ff264831edb0534e3496 (patch) | |
| tree | 8ecd0fa3db6c3d084f064f6f9c96009f3c7f12fd /src/LivingVillage.Desktop/SampleScript.fs | |
| parent | fc77481196aaea817eec991bdcf4dc22bb1d245c (diff) | |
| download | living-village-dd0907573e13981035a5ff264831edb0534e3496.tar.gz | |
feat(desktop): LV_AUTOPLAY_SAMPLE 情境演示脚本
新增 SampleScript 状态机:进门→内景驻留→出门→走向 NPC
→打开对话→发送意图→关闭面板→走开,超时兜底进 SampleComplete。
Game.fs 读取 LV_AUTOPLAY_SAMPLE=1 切换 sample 模式(2x 模拟、
关闭旧 step 倍率、UpdateMenu 常开、独立 UpdatePlaying 分发)。
SampleTests 覆盖首步行走、进门、完整巡游(42/42 通过)。
Diffstat (limited to 'src/LivingVillage.Desktop/SampleScript.fs')
| -rw-r--r-- | src/LivingVillage.Desktop/SampleScript.fs | 161 |
1 files changed, 161 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop/SampleScript.fs b/src/LivingVillage.Desktop/SampleScript.fs new file mode 100644 index 0000000..c072161 --- /dev/null +++ b/src/LivingVillage.Desktop/SampleScript.fs @@ -0,0 +1,161 @@ +namespace LivingVillage.Desktop + +open System +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop.VillagePresentation + +module SampleScript = + + type SampleStage = + | ApproachDoor + | InspectInterior + | LeaveDoorstep + | ApproachNpc + | ReadDialogueOptions + | CloseDialogue + | WalkAway + | SampleComplete + + type SampleAction = + | NoAction + | MoveTo of Vec2 + | PushM5 of M5Command + + type SampleState = + { Stage: SampleStage + StageFrames: int + TotalFrames: int + DialogueTarget: NpcId option + TimedOut: bool } + + let initial : SampleState = + { Stage = ApproachDoor + StageFrames = 0 + TotalFrames = 0 + DialogueTarget = None + TimedOut = false } + + let private tileCenter (tile: TilePosition) : Vec2 = + { X = float32 (tile.X * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (tile.Y * Sim.tilePixels + Sim.tilePixels / 2) } + + let doorWorldCenter : Vec2 = + let home = (createScene ()).Homes |> List.head + tileCenter (worldTileForSceneTile home.Door) + + let private leaveDoorstepCenter : Vec2 = + tileCenter (worldTileForSceneTile { X = 3; Y = 5 }) + + let private walkAwayCenter : Vec2 = + tileCenter (worldTileForSceneTile { X = 6; Y = 7 }) + + let private arriveRadius = 12.0f + let private inspectFrames = 150 + let private dialogueHoldFrames = 120 + let private totalFrameLimit = 20000 + + let private distanceSquared (a: Vec2) (b: Vec2) : float32 = + let dx = b.X - a.X + let dy = b.Y - a.Y + dx * dx + dy * dy + + let private timeoutFor (stage: SampleStage) : int = + match stage with + | ApproachDoor -> 1800 + | InspectInterior -> 600 + | LeaveDoorstep -> 1800 + | ApproachNpc -> 5400 + | ReadDialogueOptions -> 120 + | CloseDialogue -> 300 + | WalkAway -> 3600 + | SampleComplete -> Int32.MaxValue + + let private advance (stage: SampleStage) (state: SampleState) : SampleState = + { state with Stage = stage; StageFrames = 0 } + + let private nearestChatableNpc (world: World) : Npc option = + world.Npcs + |> Array.filter chatableForChat + |> Array.sortBy (fun npc -> distanceSquared world.Avatar.Pos npc.Pos, npc.Id) + |> Array.tryHead + + let private timedOut (state: SampleState) : SampleState * SampleAction = + { state with Stage = SampleComplete; TimedOut = true }, NoAction + + let step (world: World) (view: M5View) (state: SampleState) : SampleState * SampleAction = + let state = + { state with StageFrames = state.StageFrames + 1; TotalFrames = state.TotalFrames + 1 } + if state.TotalFrames > totalFrameLimit then + timedOut state + elif state.StageFrames >= timeoutFor state.Stage then + timedOut state + else + match state.Stage with + | SampleComplete -> state, NoAction + | ApproachDoor -> + match view.HomeMode with + | Inside _ -> advance InspectInterior state, NoAction + | Outside -> + if distanceSquared world.Avatar.Pos doorWorldCenter <= arriveRadius * arriveRadius then + state, PushM5 Interact + else + state, MoveTo doorWorldCenter + | InspectInterior -> + match view.HomeMode with + | Outside -> advance LeaveDoorstep state, NoAction + | Inside _ -> + if state.StageFrames >= inspectFrames then + state, PushM5 Interact + else + state, NoAction + | LeaveDoorstep -> + match view.HomeMode with + | Inside _ -> state, PushM5 Interact + | Outside -> + if distanceSquared world.Avatar.Pos leaveDoorstepCenter <= arriveRadius * arriveRadius then + advance ApproachNpc state, NoAction + else + state, MoveTo leaveDoorstepCenter + | ApproachNpc -> + match M5Interaction.resolveAction world Outside with + | Some { Action = TalkTo target } -> + { advance ReadDialogueOptions state with DialogueTarget = Some target }, PushM5 Interact + | _ -> + match nearestChatableNpc world with + | Some npc -> state, MoveTo npc.Pos + | None -> state, NoAction + | ReadDialogueOptions -> + if view.Panel = DialoguePanel && view.Menu.IsSome then + state, PushM5 Intent1 + else + advance CloseDialogue state, NoAction + | CloseDialogue -> + if state.StageFrames >= dialogueHoldFrames then + advance WalkAway state, PushM5 ClosePanel + else + state, NoAction + | WalkAway -> + if distanceSquared world.Avatar.Pos walkAwayCenter <= arriveRadius * arriveRadius then + advance SampleComplete state, NoAction + else + state, MoveTo walkAwayCenter + + let runFrame (world: World) (view: M5View) (state: SampleState) : World * M5View * SampleState = + let nextState, action = step world view state + let nextWorld, nextView = + match action with + | NoAction -> world, view + | PushM5 command -> M5Interaction.apply command world view + | MoveTo target -> + let dx = target.X - world.Avatar.Pos.X + let dy = target.Y - world.Avatar.Pos.Y + let length = sqrt (dx * dx + dy * dy) + let input = + if length > 0.001f then + { MoveX = dx / length; MoveY = dy / length } + else + { MoveX = 0.0f; MoveY = 0.0f } + let movedWorld = Sim.step { Input = input } world + movedWorld, view + nextWorld, M5Interaction.refreshPrompt nextWorld nextView, nextState |
