From c5ff7685e5b3ac5dc46fce74ef1b302de09af1e6 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Sun, 20 Sep 2026 21:16:49 +0800 Subject: feat(desktop): add Chinese village presentation prototype [Change Nature] - This commit adds a deterministic presentation-model feature, not MonoGame rendering. [New Capability] - Provide an immutable Jiangnan village scene with homes, waterways, paths, bridge, bamboo, and garden elements. - Provide typed home transitions, four-way input mapping, tick animation, NPC styles, and Chinese stage-one labels. [Implementation] - Add the pure VillagePresentation module and six focused MSTest cases. - Keep the model free of mutable global state and update only the required F# compile order. [Impact] - This commit affects the Desktop presentation and test projects only. - Kernel behavior, Game.fs wiring, MonoGame drawing, and existing acceptance artifacts remain unchanged. --- .../LivingVillage.Desktop.Tests.fsproj | 1 + src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 102 ++++++++++++ .../LivingVillage.Desktop.fsproj | 1 + src/LivingVillage.Desktop/VillagePresentation.fs | 171 +++++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 src/LivingVillage.Desktop.Tests/PrototypeTests.fs create mode 100644 src/LivingVillage.Desktop/VillagePresentation.fs diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index 405e8b5..569190a 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -9,6 +9,7 @@ + diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs new file mode 100644 index 0000000..b959d70 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -0,0 +1,102 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop.VillagePresentation + +[] +type PrototypeTests () = + + [] + member _.SceneContainsTheJiangnanPrototypeAndReachableHomeDoor () = + let first = createScene () + let second = createScene () + let requiredProps = + [ WhiteWallDarkTileHome + HomeDoor + RiverChannel + RiverBank + StoneBridge + StonePaving + Bamboo + VegetableGarden ] + + Assert.AreEqual(first, second) + Assert.AreEqual(scene, first) + Assert.IsTrue(first.Homes.Length > 0, "The prototype needs at least one home.") + Assert.IsTrue( + requiredProps + |> List.forall (fun prop -> first.Elements |> List.exists (fun element -> element.Prop = prop)), + "The scene is missing one or more required Jiangnan elements.") + + let home = first.Homes |> List.head + Assert.IsTrue(isNearHomeDoor first.InitialPlayerPosition home) + + [] + member _.HomeEntryAndExitExposeExplicitSuccessAndFailureBranches () = + let scene = createScene () + let home = scene.Homes |> List.head + let entered = enterHome scene.InitialPlayerPosition Outside + + match entered with + | Ok (Inside enteredHome) -> + Assert.AreEqual(home.Id, enteredHome) + | Ok Outside -> Assert.Fail("Entering a home must produce Inside state.") + | Error failure -> Assert.Fail(sprintf "Expected entry to succeed, got %A." failure) + + match entered with + | Ok inside -> + match enterHome scene.InitialPlayerPosition inside with + | Error (AlreadyInsideHome homeId) -> Assert.AreEqual(home.Id, homeId) + | other -> Assert.Fail(sprintf "Expected AlreadyInsideHome, got %A." other) + | Error _ -> Assert.Fail("The first entry must succeed before checking the duplicate entry.") + + match exitHome (Inside home.Id) with + | Ok Outside -> () + | other -> Assert.Fail(sprintf "Expected exit to succeed, got %A." other) + + match exitHome Outside with + | Error NotInsideHome -> () + | other -> Assert.Fail(sprintf "Expected NotInsideHome, got %A." other) + + match exitHome (Inside(HomeId 999)) with + | Error(HomeNotFound(HomeId 999)) -> () + | other -> Assert.Fail(sprintf "Expected HomeNotFound, got %A." other) + + let farAway = { X = scene.InitialPlayerPosition.X + 20; Y = scene.InitialPlayerPosition.Y + 20 } + match enterHome farAway Outside with + | Error NotNearHomeDoor -> () + | other -> Assert.Fail(sprintf "Expected NotNearHomeDoor, got %A." other) + + [] + member _.FourWayInputMapsToFourDirections () = + Assert.AreEqual(Some North, directionForInput UpInput) + Assert.AreEqual(Some South, directionForInput DownInput) + Assert.AreEqual(Some West, directionForInput LeftInput) + Assert.AreEqual(Some East, directionForInput RightInput) + Assert.AreEqual(None, directionForInput NoInput) + + [] + member _.AnimationFrameChangesWithSimulationTick () = + let first = animationFrame (SimulationTick 0L) + let second = animationFrame (SimulationTick 4L) + let nextCycle = animationFrame (SimulationTick 8L) + + Assert.AreNotEqual(first, second) + Assert.AreEqual(first, nextCycle) + + [] + member _.NpcIdProducesStableAndDistinctCharacterStyles () = + let first = characterStyle (NpcId 0) + let sameNpc = characterStyle (NpcId 0) + let second = characterStyle (NpcId 1) + + Assert.AreEqual(first, sameNpc) + Assert.AreNotEqual(first, second) + + [] + member _.StageOneCopyProvidesChineseInteractionLabels () = + Assert.AreEqual("互动", stageOneCopy.Interact) + Assert.AreEqual("需求", stageOneCopy.Needs) + Assert.AreEqual("交易", stageOneCopy.Trade) + Assert.AreEqual("进入民居", stageOneCopy.EnterHome) diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 36f1216..fbb7c4a 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -6,6 +6,7 @@ + diff --git a/src/LivingVillage.Desktop/VillagePresentation.fs b/src/LivingVillage.Desktop/VillagePresentation.fs new file mode 100644 index 0000000..95dc178 --- /dev/null +++ b/src/LivingVillage.Desktop/VillagePresentation.fs @@ -0,0 +1,171 @@ +namespace LivingVillage.Desktop + +module VillagePresentation = + + (** + 中文江南水乡阶段一样板的纯展示快照。 + + TilePosition 使用整数瓦片坐标;一个坐标单位代表一块场景瓦片,原点和 + 屏幕像素换算留给后续 MonoGame 组合层。SimulationTick 使用 int64,表示 + Kernel 同一时间轴上的一个模拟 tick。PrototypeScene 以及其中的 record + 和 list 都是不可变快照,调用方只能读取,不能把它们当作可变模拟状态; + 本模块不保存可变全局状态,也不依赖 MonoGame。 + **) + + [] + type TilePosition = + { X: int + Y: int } + + [] + type SimulationTick = + | SimulationTick of int64 + + [] + type HomeId = + | HomeId of int + + type VillageProp = + | WhiteWallDarkTileHome + | HomeDoor + | RiverChannel + | RiverBank + | StoneBridge + | StonePaving + | Bamboo + | VegetableGarden + + type VillageElement = + { Prop: VillageProp + Position: TilePosition } + + type Home = + { Id: HomeId + Door: TilePosition } + + type PrototypeScene = + { Elements: VillageElement list + Homes: Home list + InitialPlayerPosition: TilePosition } + + type Direction = + | North + | South + | West + | East + + type DirectionInput = + | UpInput + | DownInput + | LeftInput + | RightInput + | NoInput + + type CharacterAnimationFrame = + | FrameA + | FrameB + + type CharacterStyle = + | StrawHatFarmer + | BlueApronVillager + | GreenScarfVillager + + type HomeMode = + | Outside + | Inside of HomeId + + type HomeTransitionError = + | NotNearHomeDoor + | AlreadyInsideHome of HomeId + | NotInsideHome + | HomeNotFound of HomeId + + type StageOneCopy = + { Interact: string + Needs: string + Trade: string + EnterHome: string } + + let private tile x y : TilePosition = + { X = x; Y = y } + + let private element prop position : VillageElement = + { Prop = prop + Position = position } + + let createScene () : PrototypeScene = + let home = + { Id = HomeId 1 + Door = tile 6 5 } + + { Elements = + [ element WhiteWallDarkTileHome (tile 6 4) + element HomeDoor home.Door + element StonePaving (tile 4 5) + element StonePaving (tile 5 5) + element StonePaving (tile 6 5) + element StonePaving (tile 7 5) + element RiverBank (tile 10 2) + element RiverBank (tile 10 3) + element RiverChannel (tile 10 4) + element RiverChannel (tile 10 5) + element StoneBridge (tile 10 4) + element Bamboo (tile 3 2) + element Bamboo (tile 3 3) + element VegetableGarden (tile 8 7) + element VegetableGarden (tile 9 7) ] + Homes = [ home ] + InitialPlayerPosition = tile 5 5 } + + let scene : PrototypeScene = + createScene () + + let private manhattanDistance first second : int = + abs (first.X - second.X) + abs (first.Y - second.Y) + + let isNearHomeDoor (position: TilePosition) (home: Home) : bool = + manhattanDistance position home.Door <= 1 + + let enterHome (position: TilePosition) (mode: HomeMode) : Result = + match mode with + | Inside homeId -> Error(AlreadyInsideHome homeId) + | Outside -> + match scene.Homes |> List.tryFind (isNearHomeDoor position) with + | Some home -> Ok(Inside home.Id) + | None -> Error NotNearHomeDoor + + let exitHome (mode: HomeMode) : Result = + match mode with + | Outside -> Error NotInsideHome + | Inside homeId -> + if scene.Homes |> List.exists (fun home -> home.Id = homeId) then + Ok Outside + else + Error(HomeNotFound homeId) + + let directionForInput (input: DirectionInput) : Direction option = + match input with + | UpInput -> Some North + | DownInput -> Some South + | LeftInput -> Some West + | RightInput -> Some East + | NoInput -> None + + let private positiveModulo divisor value = + let remainder = value % divisor + if remainder < 0L then remainder + divisor else remainder + + let animationFrame (SimulationTick tick) : CharacterAnimationFrame = + if positiveModulo 8L tick < 4L then FrameA else FrameB + + let characterStyle (LivingVillage.Kernel.Sim.NpcId npcId) : CharacterStyle = + match ((npcId % 3) + 3) % 3 with + | 0 -> StrawHatFarmer + | 1 -> BlueApronVillager + | _ -> GreenScarfVillager + + let stageOneCopy : StageOneCopy = + { Interact = "互动" + Needs = "需求" + Trade = "交易" + EnterHome = "进入民居" } -- cgit v1.2.3