summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/assets-and-licenses.md3
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs72
-rw-r--r--src/LivingVillage.Desktop/ChineseText.fs84
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj2
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs95
5 files changed, 255 insertions, 1 deletions
diff --git a/docs/assets-and-licenses.md b/docs/assets-and-licenses.md
index 372979b..3255dda 100644
--- a/docs/assets-and-licenses.md
+++ b/docs/assets-and-licenses.md
@@ -2,8 +2,9 @@
## 仓库和交付包中的实际资产
-- **代码生成像素占位图**:`src/LivingVillage.Desktop/Game.fs` 中的 `PixelText`、色块和 `Atlas` 绘制逻辑在运行时生成图形,没有独立图片文件,不主张外部素材来源。
+- **原创代码生成图形与 procedural data**:`src/LivingVillage.Desktop/Game.fs` 中的 `PixelText`、色块和 `Atlas` 绘制逻辑,以及 `VillageArt.fs` 和 `ChineseText.fs` 中的江南场景数据、NPC 视觉元数据和中文排版策略,均由仓库代码在运行时或纯函数中生成,没有独立图片文件,不主张外部素材来源。
- **外部美术资产**:当前仓库没有 PNG、JPG、GIF、SVG、字体或音频文件。设计文档提到的 Kenney CC 资产只是候选方向,未随仓库或 M6c 包分发。
+- **Task 3a 边界**:本切片不依赖下载的第三方美术或字体素材;交付内容仅包含原创 procedural data/code 和现有运行依赖。
## 第三方运行依赖
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index 3abb064..a4bd1ea 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -5,6 +5,8 @@ open Microsoft.VisualStudio.TestTools.UnitTesting
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
open LivingVillage.Desktop
+open LivingVillage.Desktop.ChineseText
+open LivingVillage.Desktop.VillageArt
open LivingVillage.Desktop.VillagePresentation
[<TestClass>]
@@ -176,3 +178,73 @@ type PrototypeTests () =
Assert.AreEqual<string>("1倍速", M6Presentation.clockLabel { Paused = false; Speed = OneX })
Assert.AreEqual<string>("2倍速", M6Presentation.clockLabel { Paused = false; Speed = TwoX })
Assert.AreEqual<string>("5倍速", M6Presentation.clockLabel { Paused = false; Speed = FiveX })
+
+ [<TestMethod>]
+ member _.JiangnanArtPlanIsDeterministicAndContainsRequiredElements () =
+ let first = samplePlan ()
+ let second = samplePlan ()
+ let requiredProps =
+ [ VillageArt.WhiteWallDarkTileHouse
+ VillageArt.StoneBridge
+ VillageArt.Bamboo
+ VillageArt.VegetableGarden ]
+
+ Assert.AreEqual<JiangnanArtPlan>(first, second)
+ Assert.IsTrue(
+ requiredProps
+ |> List.forall (fun prop -> first.Props |> List.exists (fun sprite -> sprite.Kind = prop)),
+ "The Jiangnan plan is missing one or more required props.")
+ Assert.IsTrue(first.Tiles |> List.exists (fun tile -> tile.Kind = (VillageArt.RiverWater)))
+ Assert.IsTrue(first.Tiles |> List.exists (fun tile -> tile.Kind = (VillageArt.StonePaving)))
+
+ [<TestMethod>]
+ member _.NpcVariantsAreStableAndAnimationCoversFourDirectionsAndTwoFrames () =
+ let sameNpc = npcVisualVariant (NpcId 7)
+ let repeatedNpc = npcVisualVariant (NpcId 7)
+ let otherNpc = npcVisualVariant (NpcId 8)
+ let directions =
+ [ NorthFacing
+ SouthFacing
+ WestFacing
+ EastFacing ]
+ let sprites = directions |> List.map (fun direction -> npcSpriteSpec (NpcId 7) direction 4L)
+
+ Assert.AreEqual<NpcVisualVariant>(sameNpc, repeatedNpc)
+ Assert.AreNotEqual<NpcVisualVariant>(sameNpc, otherNpc)
+ Assert.AreEqual<int>(4, sprites |> List.map (fun sprite -> sprite.Direction) |> List.distinct |> List.length)
+ Assert.IsTrue(sprites |> List.forall (fun sprite -> sprite.Frame = FrameTwo))
+ Assert.AreEqual<AnimationFrame>(FrameOne, (VillageArt.animationFrame 0L))
+ Assert.AreEqual<AnimationFrame>(FrameTwo, (VillageArt.animationFrame 4L))
+ Assert.AreEqual<AnimationFrame>(FrameOne, (VillageArt.animationFrame 8L))
+
+ [<TestMethod>]
+ member _.ChineseCopyProvidesFormalSceneTimeMenuAndStatusLabels () =
+ let labels =
+ [ sceneLabel VillageScene
+ sceneLabel Waterway
+ sceneLabel WhiteWallHome
+ sceneLabel StoneBridgeScene
+ sceneLabel BambooGrove
+ sceneLabel VegetableGardenScene
+ dayNightLabel DayTime
+ dayNightLabel NightTime
+ menuHeading MainMenuHeading
+ menuHeading SettingsHeading
+ menuHeading ControlsHeading
+ statusHeading StatusHeading
+ statusHeading ReadyStatus
+ statusHeading PausedStatus ]
+
+ Assert.IsTrue(labels |> List.forall (fun label -> not (String.IsNullOrWhiteSpace label)))
+ Assert.IsTrue(labels |> List.forall (fun label -> not (label.Contains("?"))))
+ Assert.AreEqual<string>("江南水乡", copy.SceneTitle)
+ Assert.AreEqual<string>("准备就绪", copy.Ready)
+
+ [<TestMethod>]
+ member _.ChineseTextWrapsAndUsesAnExplicitMissingGlyphPolicy () =
+ let supported = Set.ofList [ '江'; '南'; '水'; '乡' ]
+ let layout = layoutText 4 supported "江南水乡村"
+
+ Assert.AreEqual<string list>([ "江南水乡"; "□" ], layout.Lines)
+ Assert.AreEqual<char list>([ '村' ], layout.MissingGlyphs)
+ Assert.IsFalse(String.concat "" layout.Lines |> fun text -> text.Contains("?"))
diff --git a/src/LivingVillage.Desktop/ChineseText.fs b/src/LivingVillage.Desktop/ChineseText.fs
new file mode 100644
index 0000000..b0c2fc6
--- /dev/null
+++ b/src/LivingVillage.Desktop/ChineseText.fs
@@ -0,0 +1,84 @@
+namespace LivingVillage.Desktop
+
+open System
+
+module ChineseText =
+
+ type SceneKind =
+ | VillageScene
+ | Waterway
+ | WhiteWallHome
+ | StoneBridgeScene
+ | BambooGrove
+ | VegetableGardenScene
+
+ type DayNightKind =
+ | DayTime
+ | NightTime
+
+ type MenuHeadingKind =
+ | MainMenuHeading
+ | SettingsHeading
+ | ControlsHeading
+
+ type StatusHeadingKind =
+ | StatusHeading
+ | ReadyStatus
+ | PausedStatus
+
+ type ChineseCopy =
+ { SceneTitle: string
+ Ready: string }
+
+ type TextLayout =
+ { Lines: string list
+ MissingGlyphs: char list }
+
+ let sceneLabel scene =
+ match scene with
+ | VillageScene -> "江南水乡"
+ | Waterway -> "水道"
+ | WhiteWallHome -> "白墙黛瓦民居"
+ | StoneBridgeScene -> "石桥"
+ | BambooGrove -> "竹林"
+ | VegetableGardenScene -> "菜圃"
+
+ let dayNightLabel time =
+ match time with
+ | DayTime -> "白天"
+ | NightTime -> "夜晚"
+
+ let menuHeading heading =
+ match heading with
+ | MainMenuHeading -> "主菜单"
+ | SettingsHeading -> "设置"
+ | ControlsHeading -> "操作说明"
+
+ let statusHeading status =
+ match status with
+ | StatusHeading -> "状态"
+ | ReadyStatus -> "准备就绪"
+ | PausedStatus -> "已暂停"
+
+ let copy : ChineseCopy =
+ { SceneTitle = "江南水乡"
+ Ready = "准备就绪" }
+
+ let layoutText (maxWidth: int) (supported: Set<char>) (text: string) : TextLayout =
+ let width = max 1 maxWidth
+ let normalized, missing =
+ text
+ |> Seq.fold
+ (fun (characters, missingGlyphs) character ->
+ if Set.contains character supported then
+ character :: characters, missingGlyphs
+ else
+ '□' :: characters, character :: missingGlyphs)
+ ([], [])
+
+ { Lines =
+ normalized
+ |> List.rev
+ |> List.chunkBySize width
+ |> List.map (fun line -> new String(Array.ofList line))
+ MissingGlyphs = List.rev missing }
diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
index fbb7c4a..171ba71 100644
--- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
+++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
@@ -7,6 +7,8 @@
<ItemGroup>
<Compile Include="VillagePresentation.fs" />
+ <Compile Include="VillageArt.fs" />
+ <Compile Include="ChineseText.fs" />
<Compile Include="MenuState.fs" />
<Compile Include="Interaction.fs" />
<Compile Include="M6Presentation.fs" />
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
new file mode 100644
index 0000000..0be4786
--- /dev/null
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -0,0 +1,95 @@
+namespace LivingVillage.Desktop
+
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop.VillagePresentation
+
+module VillageArt =
+
+ type PropKind =
+ | WhiteWallDarkTileHouse
+ | StoneBridge
+ | Bamboo
+ | VegetableGarden
+
+ type TileKind =
+ | RiverWater
+ | StonePaving
+
+ type ArtProp =
+ { Kind: PropKind
+ Position: TilePosition }
+
+ type ArtTile =
+ { Kind: TileKind
+ Position: TilePosition }
+
+ type JiangnanArtPlan =
+ { Props: ArtProp list
+ Tiles: ArtTile list }
+
+ type NpcVisualVariant =
+ | IndigoRobe
+ | OchreVest
+ | JadeSash
+
+ type FacingDirection =
+ | NorthFacing
+ | SouthFacing
+ | WestFacing
+ | EastFacing
+
+ type AnimationFrame =
+ | FrameOne
+ | FrameTwo
+
+ type NpcSpriteSpec =
+ { Variant: NpcVisualVariant
+ Direction: FacingDirection
+ Frame: AnimationFrame }
+
+ let private position x y : TilePosition =
+ { X = x
+ Y = y }
+
+ let private prop kind x y : ArtProp =
+ { Kind = kind
+ Position = position x y }
+
+ let private tile kind x y : ArtTile =
+ { Kind = kind
+ Position = position x y }
+
+ let samplePlan () : JiangnanArtPlan =
+ { Props =
+ [ prop WhiteWallDarkTileHouse 6 4
+ prop StoneBridge 10 4
+ prop Bamboo 3 2
+ prop VegetableGarden 8 7 ]
+ Tiles =
+ [ tile RiverWater 10 2
+ tile RiverWater 10 3
+ tile StonePaving 4 5
+ tile StonePaving 5 5
+ tile StonePaving 6 5 ] }
+
+ let private positiveModulo divisor value =
+ let remainder = value % divisor
+ if remainder < 0 then remainder + divisor else remainder
+
+ let npcVisualVariant (NpcId npcId) : NpcVisualVariant =
+ match positiveModulo 3 npcId with
+ | 0 -> IndigoRobe
+ | 1 -> OchreVest
+ | _ -> JadeSash
+
+ let private positiveModulo64 divisor value =
+ let remainder = value % divisor
+ if remainder < 0L then remainder + divisor else remainder
+
+ let animationFrame (tick: int64) : AnimationFrame =
+ if positiveModulo64 8L tick < 4L then FrameOne else FrameTwo
+
+ let npcSpriteSpec (npcId: NpcId) (direction: FacingDirection) (tick: int64) : NpcSpriteSpec =
+ { Variant = npcVisualVariant npcId
+ Direction = direction
+ Frame = animationFrame tick }