summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-20 21:16:49 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-20 21:16:49 +0800
commitc5ff7685e5b3ac5dc46fce74ef1b302de09af1e6 (patch)
treec1fccb5d44bd6b055f5f1521ea259446e77057a5
parent5cb4970e2410a43e528b4b3e44dda15fac0a219a (diff)
downloadliving-village-c5ff7685e5b3ac5dc46fce74ef1b302de09af1e6.tar.gz
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.
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs102
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj1
-rw-r--r--src/LivingVillage.Desktop/VillagePresentation.fs171
4 files changed, 275 insertions, 0 deletions
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 @@
<Compile Include="DesktopTests.fs" />
<Compile Include="M6aTests.fs" />
<Compile Include="M6bTests.fs" />
+ <Compile Include="PrototypeTests.fs" />
</ItemGroup>
<ItemGroup>
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
+
+[<TestClass>]
+type PrototypeTests () =
+
+ [<TestMethod>]
+ member _.SceneContainsTheJiangnanPrototypeAndReachableHomeDoor () =
+ let first = createScene ()
+ let second = createScene ()
+ let requiredProps =
+ [ WhiteWallDarkTileHome
+ HomeDoor
+ RiverChannel
+ RiverBank
+ StoneBridge
+ StonePaving
+ Bamboo
+ VegetableGarden ]
+
+ Assert.AreEqual<PrototypeScene>(first, second)
+ Assert.AreEqual<PrototypeScene>(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)
+
+ [<TestMethod>]
+ 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<HomeId>(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<HomeId>(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)
+
+ [<TestMethod>]
+ member _.FourWayInputMapsToFourDirections () =
+ Assert.AreEqual<Direction option>(Some North, directionForInput UpInput)
+ Assert.AreEqual<Direction option>(Some South, directionForInput DownInput)
+ Assert.AreEqual<Direction option>(Some West, directionForInput LeftInput)
+ Assert.AreEqual<Direction option>(Some East, directionForInput RightInput)
+ Assert.AreEqual<Direction option>(None, directionForInput NoInput)
+
+ [<TestMethod>]
+ member _.AnimationFrameChangesWithSimulationTick () =
+ let first = animationFrame (SimulationTick 0L)
+ let second = animationFrame (SimulationTick 4L)
+ let nextCycle = animationFrame (SimulationTick 8L)
+
+ Assert.AreNotEqual<CharacterAnimationFrame>(first, second)
+ Assert.AreEqual<CharacterAnimationFrame>(first, nextCycle)
+
+ [<TestMethod>]
+ member _.NpcIdProducesStableAndDistinctCharacterStyles () =
+ let first = characterStyle (NpcId 0)
+ let sameNpc = characterStyle (NpcId 0)
+ let second = characterStyle (NpcId 1)
+
+ Assert.AreEqual<CharacterStyle>(first, sameNpc)
+ Assert.AreNotEqual<CharacterStyle>(first, second)
+
+ [<TestMethod>]
+ member _.StageOneCopyProvidesChineseInteractionLabels () =
+ Assert.AreEqual<string>("互动", stageOneCopy.Interact)
+ Assert.AreEqual<string>("需求", stageOneCopy.Needs)
+ Assert.AreEqual<string>("交易", stageOneCopy.Trade)
+ Assert.AreEqual<string>("进入民居", 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 @@
</PropertyGroup>
<ItemGroup>
+ <Compile Include="VillagePresentation.fs" />
<Compile Include="MenuState.fs" />
<Compile Include="Interaction.fs" />
<Compile Include="M6Presentation.fs" />
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。
+ **)
+
+ [<Struct>]
+ type TilePosition =
+ { X: int
+ Y: int }
+
+ [<Struct>]
+ type SimulationTick =
+ | SimulationTick of int64
+
+ [<Struct>]
+ 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<HomeMode, HomeTransitionError> =
+ 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<HomeMode, HomeTransitionError> =
+ 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 = "进入民居" }