diff options
| -rw-r--r-- | README.md | 10 | ||||
| -rw-r--r-- | docs/assets-and-licenses.md | 12 | ||||
| -rw-r--r-- | docs/chinese-village-art-direction.md | 18 | ||||
| -rw-r--r-- | docs/release-notes-m6c.md | 1 | ||||
| -rwxr-xr-x | scripts/generate-cjk-glyph-atlas.sh | 98 | ||||
| -rwxr-xr-x | scripts/m6c-package.sh | 19 | ||||
| -rwxr-xr-x | scripts/macos-package.sh | 18 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 5 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 172 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png | bin | 0 -> 50697 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cjk-glyphs.txt | 188 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/ChineseText.fs | 53 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/CjkGlyphAtlas.fs | 55 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 261 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 209 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj | 6 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 428 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillagePresentation.fs | 45 |
18 files changed, 1406 insertions, 192 deletions
@@ -3,9 +3,9 @@ NPC 社会涌现沙盘:扮演村庄 30 人中的一员过日子,底层是完整模拟的 NPC 社会。 - 设计文档:`docs/design/living-village-design.md` -- 技术栈:F# + MonoGame + Mibo(MVU) +- 技术栈:F# + .NET 8 + MonoGame DesktopGL - 开发协议:全部编码由 OpenCode 执行(ark-plan/glm-5.3-flash);Hermes 只做需求派发与验收 -- 里程碑 M0-M6 见设计文档第六节 +- 当前交付:M6c Linux x64 可运行包 ## Linux 交付 @@ -17,7 +17,7 @@ NPC 社会涌现沙盘:扮演村庄 30 人中的一员过日子,底层是完 ./scripts/m6c-package.sh ``` -产物输出到 `artifacts/m6c-release/<UTC timestamp>/`。包目录包含 `app/`、`run-living-village.sh`、运行模式元数据和交付文档。`artifacts/` 已被 `.gitignore` 忽略,不应提交大包或运行日志。 +产物输出到 `artifacts/m6c-release/<UTC timestamp>/`。包目录包含 `app/`、`app/Assets/cjk-glyph-atlas.png`、`app/Assets/cjk-glyphs.txt`、`run-living-village.sh`、运行模式元数据和交付文档。`artifacts/` 已被 `.gitignore` 忽略,不应提交大包或运行日志。 如果 self-contained publish 受环境或依赖限制失败,脚本会回退到 framework-dependent publish,并在 `PACKAGE-INFO.txt` 中写明失败状态和所需的系统 .NET 8 runtime。 @@ -79,12 +79,12 @@ LV_AUTOPLAY_FLOW=1 xvfb-run -a ./run-living-village.sh M0-M6 的落地摘要和实际验收口径见 [`docs/release-notes-m6c.md`](docs/release-notes-m6c.md)。 -资产与许可证边界见 [`docs/assets-and-licenses.md`](docs/assets-and-licenses.md)。仓库没有随包分发的 PNG、字体、音频或 Kenney 素材;当前像素占位图由代码生成,不能把设计文档中的候选资产描述成已包含资产。 +资产与许可证边界见 [`docs/assets-and-licenses.md`](docs/assets-and-licenses.md)。仓库和包会分发由许可明确的 Noto Sans CJK 字体生成的 `Assets/cjk-glyph-atlas.png` 及其字符清单,但不分发原始字体文件、音频或 Kenney 素材;其他场景像素图由代码生成。 ## 当前已知限制 - 这是 Linux x64 DesktopGL 交付包;self-contained 不会打包发行版的 X11、OpenGL、SDL2、OpenAL 等原生库。 -- 当前美术是代码生成的像素占位图,没有外部美术资产或音频。 +- 当前场景美术是代码生成的像素占位图;中文 UI 使用仓库内的 Noto Sans CJK 派生字形图集,没有随包携带原始字体或音频。 - `LV_AUTOPLAY_FLOW` 是验收驱动,不是完整的键盘用户体验替代品。 - 默认世界使用固定种子 `42` 和 30 NPC;更长时间稳定性仍应以 headless 批量验收为准。 - 包含完整源码构建入口,但本交付不承诺安装器、桌面文件或发行版仓库包。 diff --git a/docs/assets-and-licenses.md b/docs/assets-and-licenses.md index 3255dda..f2dc1bc 100644 --- a/docs/assets-and-licenses.md +++ b/docs/assets-and-licenses.md @@ -2,9 +2,13 @@ ## 仓库和交付包中的实际资产 -- **原创代码生成图形与 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 和现有运行依赖。 +- **原创代码生成图形与 procedural data**:`src/LivingVillage.Desktop/Game.fs` 中的 `PixelText`、色块和 `Atlas` 绘制逻辑,以及 `VillageArt.fs` 和 `ChineseText.fs` 中的江南场景数据、NPC 视觉元数据和中文排版策略,均由仓库代码在运行时或纯函数中生成,不主张外部素材来源。 +- **Noto Sans CJK 派生字形图集**:`src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png` 是仓库实际分发的 `384x288` 透明 PNG,使用 `src/LivingVillage.Desktop/Assets/cjk-glyphs.txt` 中的 188 个字符、16 列 24px 单元生成;两者会复制到 Linux/macOS 发布包的 `Assets/` 目录。原始字体文件不随仓库或包分发。 + - 来源:`/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc`,字体族包含 Noto Sans CJK SC。 + - 许可:SIL Open Font License 1.1;本地版权文件 SHA-256:`849f4ea9c214fa4ac3593b770c699f387534b11ce671264c1b10d85bdcb5997b`。 + - 参考:<https://raw.githubusercontent.com/notofonts/noto-cjk/main/README.md>。 +- **其他外部美术资产**:当前仓库没有 JPG、GIF、SVG、其他字体或音频文件。设计文档提到的 Kenney CC 资产只是候选方向,未随仓库或 M6c 包分发。 +- **Task 3b 边界**:中文 UI 使用上述许可明确的派生字形图集;场景、角色和其他 UI 图形仍由仓库代码生成,不下载或声称拥有未包含的第三方素材。 ## 第三方运行依赖 @@ -13,4 +17,4 @@ ## 再分发边界 -M6c 没有复制或声称拥有外部艺术、字体、音频或 Kenney 素材。发布前若将 `app/` 二进制包提供给第三方,应同时保留 NuGet/runtime 上游要求的许可证和 notices;本仓库只记录实际存在并实际进入交付边界的依赖,不虚构未包含资产的来源。 +M6c 不复制原始 Noto 字体文件,也不声称拥有 Kenney、音频或其他未包含素材;它会分发由 OFL 字体生成的 PNG 字形图集。发布前若将 `app/` 二进制包提供给第三方,应同时保留本清单以及 NuGet/runtime 上游要求的许可证和 notices;本仓库只记录实际存在并实际进入交付边界的依赖,不虚构未包含资产的来源。 diff --git a/docs/chinese-village-art-direction.md b/docs/chinese-village-art-direction.md new file mode 100644 index 0000000..d951f83 --- /dev/null +++ b/docs/chinese-village-art-direction.md @@ -0,0 +1,18 @@ +# 《活着的村庄》中文与中式乡村美术方向 + +## 用户已确认 +- 玩家可见内容使用简体中文。 +- 需要完整美术,现有方块、条纹地形和英文调试HUD不能作为正式视觉交付。 +- 场景方向:中式乡村。 + +## 已获用户确认的具体方案 +用户已明确回复“可以”,批准以下江南水乡像素风方案与先做可玩样板、视觉确认后扩展全村的顺序。 +采用架空的江南水乡小村,俯视角像素风、低饱和自然配色;白墙黛瓦、石板小路、河埠石桥、竹木和菜园,普通村民衣着,不擅自设定朝代,不加入仙侠宫殿或城市场景。保留F# + MonoGame和既有社会模拟。 + +先完成一个可交互美术样板:一栋可进入民居、小桥河岸与道路、玩家和少量可区分村民、中文对话/交易/需求UI、昼夜对照。必须实际游戏内渲染并提供截图或短视频,通过用户视觉确认后扩展全村。概念图不能冒充游戏截图。 + +后续完整范围:村庄布局与内外场景、角色待机/四向走路及必要行为动画、交易物品与家具图标、全中文菜单/存读档/错误和操作提示/年鉴/关系图、可读夜景、交互反馈。按资源清单逐项核查,禁把占位图宣称完整美术。 + +素材优先选择许可明确且允许所需使用方式的统一资产并补绘;下载前核对来源、许可证及署名条件,付款需用户批准。AI生成仅辅助,检查一致性、瓦片拼接、动画连续性和透明边缘。代码与资源集成由OpenCode完成;不因美术任务破坏已冻结批验。 + +此文件记录已确认方向与提案边界,不授权将未确认细节作为用户决定,不表示资产已制作或安装。 diff --git a/docs/release-notes-m6c.md b/docs/release-notes-m6c.md index 8faeac6..8b620ab 100644 --- a/docs/release-notes-m6c.md +++ b/docs/release-notes-m6c.md @@ -14,6 +14,7 @@ M6c 将已经完成 M6b 验收的 Living Village 制作为 Linux x64 可运行� - **M5**:WASD 玩家操控、互动、对话意图、观察、需求面板、编年史和关系视图。 - **M6**:世界存档、读档、模拟变速、暂停、昼夜表现和代码生成像素占位渲染。 - **M6b**:主菜单、设置、控制页、继续/新游戏/读档/退出、读档错误页、Esc 返回语义和可审计 autoplay 流程。 +- **Task 3b 中文字形边界**:中文 UI 使用仓库内的 Noto Sans CJK 派生 PNG 图集和逐字字符清单;原始 TTC 字体不进入发布包,具体来源、OFL 1.1 许可和哈希见 [`docs/assets-and-licenses.md`](assets-and-licenses.md)。 ## 实际验收口径 diff --git a/scripts/generate-cjk-glyph-atlas.sh b/scripts/generate-cjk-glyph-atlas.sh new file mode 100755 index 0000000..01a1d93 --- /dev/null +++ b/scripts/generate-cjk-glyph-atlas.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C.UTF-8 + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" +FONT_PATH="${CJK_FONT_PATH:-/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc}" +MANIFEST="$REPO_ROOT/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt" +OUTPUT="$REPO_ROOT/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png" +CELL_PIXELS=24 +COLUMNS=16 +POINT_SIZE=20 + +for command_name in convert identify; do + if ! command -v "$command_name" >/dev/null 2>&1; then + printf 'Required command is missing: %s\n' "$command_name" >&2 + exit 127 + fi +done + +if [[ ! -f "$FONT_PATH" ]]; then + printf 'CJK font not found: %s\n' "$FONT_PATH" >&2 + exit 2 +fi + +if [[ ! -f "$MANIFEST" ]]; then + printf 'CJK glyph manifest not found: %s\n' "$MANIFEST" >&2 + exit 2 +fi + +mapfile -t manifest_lines < "$MANIFEST" +character_count=${#manifest_lines[@]} +if (( character_count == 0 )); then + printf 'CJK glyph manifest is empty: %s\n' "$MANIFEST" >&2 + exit 2 +fi + +characters="" +declare -A seen_characters=() +for index in "${!manifest_lines[@]}"; do + character="${manifest_lines[$index]}" + if [[ ${#character} -ne 1 ]]; then + printf 'CJK glyph manifest line %s must contain exactly one character: %s\n' "$((index + 1))" "$MANIFEST" >&2 + exit 2 + fi + if [[ ${seen_characters[$character]+present} ]]; then + printf 'CJK glyph manifest contains a duplicate character on line %s: %s\n' "$((index + 1))" "$MANIFEST" >&2 + exit 2 + fi + seen_characters[$character]=1 + characters+="$character" +done + +rows=$(( (character_count + COLUMNS - 1) / COLUMNS )) +width=$(( COLUMNS * CELL_PIXELS )) +height=$(( rows * CELL_PIXELS )) +temporary_root="$(mktemp -d)" +trap 'rm -rf "$temporary_root"' EXIT + +temporary_atlas="$temporary_root/cjk-glyph-atlas.png" +convert -size "${width}x${height}" xc:none -alpha on "$temporary_atlas" + +for (( index = 0; index < character_count; index++ )); do + character="${characters:index:1}" + column=$(( index % COLUMNS )) + row=$(( index / COLUMNS )) + x=$(( column * CELL_PIXELS )) + y=$(( row * CELL_PIXELS )) + cell="$temporary_root/cell-$index.png" + next_atlas="$temporary_root/atlas-$index.png" + + convert \ + -size "${CELL_PIXELS}x${CELL_PIXELS}" \ + xc:none \ + -alpha on \ + -font "$FONT_PATH" \ + -pointsize "$POINT_SIZE" \ + -fill white \ + -gravity center \ + -antialias \ + -annotate +0+0 "$character" \ + "$cell" + convert "$temporary_atlas" "$cell" -geometry "+${x}+${y}" -composite "$next_atlas" + mv "$next_atlas" "$temporary_atlas" +done + +convert "$temporary_atlas" -strip "$OUTPUT" +actual_dimensions="$(identify -format '%wx%h' "$OUTPUT")" +expected_dimensions="${width}x${height}" +if [[ "$actual_dimensions" != "$expected_dimensions" ]]; then + printf 'Generated atlas dimensions %s; expected %s\n' "$actual_dimensions" "$expected_dimensions" >&2 + exit 1 +fi + +printf 'CJK_GLYPH_ATLAS=%s\n' "$OUTPUT" +printf 'CJK_GLYPH_COUNT=%s\n' "$character_count" +printf 'CJK_GLYPH_DIMENSIONS=%s\n' "$actual_dimensions" diff --git a/scripts/m6c-package.sh b/scripts/m6c-package.sh index d887ea6..89ae6a4 100755 --- a/scripts/m6c-package.sh +++ b/scripts/m6c-package.sh @@ -46,6 +46,25 @@ else fi fi +for asset in Assets/cjk-glyph-atlas.png Assets/cjk-glyphs.txt; do + if [[ ! -s "$APP_DIR/$asset" ]]; then + printf 'Required CJK asset is missing from the Linux package: %s\n' "$APP_DIR/$asset" >&2 + exit 1 + fi +done + +mapfile -t cjk_manifest_lines < "$APP_DIR/Assets/cjk-glyphs.txt" +if [[ "${#cjk_manifest_lines[@]}" -ne 188 ]]; then + printf 'Packaged CJK manifest contains %s lines; expected 188: %s\n' "${#cjk_manifest_lines[@]}" "$APP_DIR/Assets/cjk-glyphs.txt" >&2 + exit 1 +fi +for cjk_manifest_line in "${cjk_manifest_lines[@]}"; do + if [[ ${#cjk_manifest_line} -ne 1 ]]; then + printf 'Packaged CJK manifest contains a line that is not one character: %s\n' "$APP_DIR/Assets/cjk-glyphs.txt" >&2 + exit 1 + fi +done + cp "$SCRIPT_DIR/m6c-run.sh" "$PACKAGE_ROOT/run-living-village.sh" chmod +x "$PACKAGE_ROOT/run-living-village.sh" cp "$REPO_ROOT/README.md" "$PACKAGE_ROOT/README.md" diff --git a/scripts/macos-package.sh b/scripts/macos-package.sh index 10c80b8..3255898 100755 --- a/scripts/macos-package.sh +++ b/scripts/macos-package.sh @@ -270,6 +270,24 @@ for rid in osx-arm64 osx-x64; do cp "$REPO_ROOT/docs/assets-and-licenses.md" "$app_dir/Contents/Resources/ASSETS-AND-LICENSES.md" chmod +x "$app_dir/Contents/MacOS/LivingVillage" "$app_dir/Contents/MacOS/LivingVillage.Desktop" + for asset in cjk-glyph-atlas.png cjk-glyphs.txt; do + if [[ ! -s "$app_dir/Contents/MacOS/Assets/$asset" ]]; then + printf 'BLOCKED: required CJK asset is missing for %s: %s\n' "$rid" "$app_dir/Contents/MacOS/Assets/$asset" >&2 + exit 1 + fi + done + mapfile -t cjk_manifest_lines < "$app_dir/Contents/MacOS/Assets/cjk-glyphs.txt" + if [[ "${#cjk_manifest_lines[@]}" -ne 188 ]]; then + printf 'BLOCKED: packaged CJK manifest for %s contains %s lines; expected 188\n' "$rid" "${#cjk_manifest_lines[@]}" >&2 + exit 1 + fi + for cjk_manifest_line in "${cjk_manifest_lines[@]}"; do + if [[ ${#cjk_manifest_line} -ne 1 ]]; then + printf 'BLOCKED: packaged CJK manifest for %s contains a line that is not one character\n' "$rid" >&2 + exit 1 + fi + done + if ! file -b "$app_dir/Contents/MacOS/LivingVillage.Desktop" >> "$mach_o_report"; then printf 'BLOCKED: failed to inspect bundled apphost for %s\n' "$rid" >&2 exit 1 diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index 569190a..3a9a142 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -23,4 +23,9 @@ <ProjectReference Include="..\LivingVillage.Headless\LivingVillage.Headless.fsproj" /> </ItemGroup> + <ItemGroup> + <None Include="..\LivingVillage.Desktop\Assets\cjk-glyph-atlas.png" Link="Assets\cjk-glyph-atlas.png" CopyToOutputDirectory="PreserveNewest" /> + <None Include="..\LivingVillage.Desktop\Assets\cjk-glyphs.txt" Link="Assets\cjk-glyphs.txt" CopyToOutputDirectory="PreserveNewest" /> + </ItemGroup> + </Project> diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index a4bd1ea..842ab4a 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -1,11 +1,14 @@ namespace LivingVillage.Desktop.Tests open System +open System.IO +open Microsoft.Xna.Framework open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop open LivingVillage.Desktop.ChineseText +open LivingVillage.Desktop.CjkGlyphAtlas open LivingVillage.Desktop.VillageArt open LivingVillage.Desktop.VillagePresentation @@ -68,7 +71,9 @@ type PrototypeTests () = | 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 } + let farAway : TilePosition = + { 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) @@ -224,7 +229,7 @@ type PrototypeTests () = sceneLabel Waterway sceneLabel WhiteWallHome sceneLabel StoneBridgeScene - sceneLabel BambooGrove + sceneLabel ChineseText.BambooGrove sceneLabel VegetableGardenScene dayNightLabel DayTime dayNightLabel NightTime @@ -248,3 +253,166 @@ type PrototypeTests () = Assert.AreEqual<string list>([ "江南水乡"; "□" ], layout.Lines) Assert.AreEqual<char list>([ '村' ], layout.MissingGlyphs) Assert.IsFalse(String.concat "" layout.Lines |> fun text -> text.Contains("?")) + + [<TestMethod>] + member _.DesktopPixelPositionUsesExplicitSceneTileMapping () = + let doorPixel : PixelPosition = + { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) } + + let expectedSceneTile : TilePosition = { X = 6; Y = 5 } + let expectedWorldTile : TilePosition = { X = 31; Y = 23 } + Assert.AreEqual<TilePosition>(expectedSceneTile, sceneTileFromPixel doorPixel) + Assert.AreEqual<TilePosition>(expectedWorldTile, worldTileForSceneTile expectedSceneTile) + + [<TestMethod>] + member _.HomeInteractionPrioritizesEntryExitBeforeNpcDialogue () = + let doorPixel : PixelPosition = + { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) } + let farPixel = { X = 0.0f; Y = 0.0f } + + Assert.AreEqual<HomeInputAction>(EnterHomeAction(HomeId 1), homeInputAction doorPixel Outside) + Assert.AreEqual<HomeInputAction>(ExitHomeAction(HomeId 1), homeInputAction doorPixel (Inside(HomeId 1))) + Assert.AreEqual<HomeInputAction>(DialogueAction, homeInputAction farPixel Outside) + Assert.AreEqual<HomeMode>(Outside, resetHomeMode ()) + + [<TestMethod>] + member _.JiangnanDesktopRenderPlanIsDeterministicAndHasInteriorElements () = + let first = VillageArt.sampleRenderPlan () + let second = VillageArt.sampleRenderPlan () + + Assert.AreEqual<VillageArt.JiangnanRenderPlan>(first, second) + let expectedOrigin : TilePosition = { X = 25; Y = 18 } + Assert.AreEqual<TilePosition>(expectedOrigin, first.Origin) + Assert.IsTrue(first.Grounds |> List.exists (fun (_, kind) -> kind = VillageArt.Water)) + Assert.IsTrue(first.Grounds |> List.exists (fun (_, kind) -> kind = VillageArt.StonePath)) + Assert.IsTrue(first.Props |> List.exists (fun prop -> prop.Kind = VillageArt.Bridge)) + Assert.AreEqual<int>(4, first.Interior.Elements.Length) + + [<TestMethod>] + member _.FacingUsesTheActualMovementOrTargetVector () = + Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.EastFacing, VillageArt.directionFromVector { X = 1.0f; Y = 0.1f }) + Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.WestFacing, VillageArt.directionFromVector { X = -1.0f; Y = 0.1f }) + Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.SouthFacing, VillageArt.directionFromVector { X = 0.1f; Y = 1.0f }) + Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.NorthFacing, VillageArt.directionFromVector { X = 0.1f; Y = -1.0f }) + + [<TestMethod>] + member _.RuntimeMovementAndNpcTargetsProduceStableSpriteDirections () = + let idle = VillageArt.facingAfterMovement { X = 4.0f; Y = 4.0f } { X = 4.0f; Y = 4.0f } VillageArt.EastFacing + let moved = VillageArt.facingAfterMovement { X = 4.0f; Y = 4.0f } { X = 2.0f; Y = 4.0f } VillageArt.EastFacing + let targetSpec = VillageArt.npcSpriteSpecAtTarget (NpcId 7) { X = 10.0f; Y = 10.0f } { X = 10.0f; Y = 8.0f } 4L + + Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.EastFacing, idle) + Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.WestFacing, moved) + Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.NorthFacing, targetSpec.Direction) + + [<TestMethod>] + member _.RenderPlanCoordinatesAndCharacterAtlasCellsAreDeterministic () = + let plan = VillageArt.sampleRenderPlan () + let localTile : TilePosition = { X = 6; Y = 4 } + let worldTile = VillageArt.worldTile plan localTile + let expectedWorldTile : TilePosition = { X = 31; Y = 22 } + Assert.AreEqual<TilePosition>(expectedWorldTile, worldTile) + + let first = VillageArt.characterSourceRectangle { Variant = IndigoRobe; Direction = NorthFacing; Frame = FrameOne } + let second = VillageArt.characterSourceRectangle { Variant = JadeSash; Direction = EastFacing; Frame = FrameTwo } + Assert.AreNotEqual<Rectangle>(first, second) + Assert.AreEqual<int>(32, first.Width) + Assert.AreEqual<int>(48, first.Height) + Assert.AreEqual<int>(32, second.Width) + Assert.AreEqual<int>(48, second.Height) + + [<TestMethod>] + member _.FormalChineseUiLabelsUseTheRealGlyphAtlasBoundary () = + Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall PixelText.isRenderable) + Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall (fun label -> not (label.Contains("?")))) + Assert.IsFalse(PixelText.isRenderable "龘") + + [<TestMethod>] + member _.ContextualPromptSelectsOneStableReachableNpcAndClearsWhenLeaving () = + let avatar : Vec2 = + { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (24 * Sim.tilePixels + Sim.tilePixels / 2) } + let world = + Sim.initialWorldN 42UL 4 + |> fun value -> { value with Avatar = { value.Avatar with Pos = avatar } } + let npcPosition : Vec2 = { X = world.Avatar.Pos.X + 48.0f; Y = world.Avatar.Pos.Y } + let npc = { world.Npcs.[0] with Pos = npcPosition } + let npcs = Array.copy world.Npcs + npcs.[0] <- npc + for index in 1 .. npcs.Length - 1 do + npcs.[index] <- { npcs.[index] with Pos = ({ X = 2000.0f; Y = 1400.0f } : Vec2) } + let nearby = { world with Npcs = npcs } + + let prompt = InteractionResolver.resolve nearby Outside + Assert.IsTrue(prompt.IsSome) + Assert.AreEqual<InteractionAction>(TalkTo(NpcId 0), prompt.Value.Action) + Assert.AreEqual<string>("E 交谈", prompt.Value.Text) + + let view = M5Interaction.refreshPrompt nearby M5Interaction.initial + Assert.AreEqual<string option>(Some "E 交谈", view.Prompt) + let farAway = { nearby with Avatar = { nearby.Avatar with Pos = ({ X = 0.0f; Y = 0.0f } : Vec2) } } + let cleared = M5Interaction.refreshPrompt farAway view + Assert.AreEqual<string option>(None, cleared.Prompt) + + [<TestMethod>] + member _.HomeEntryWinsDeterministicallyAndUsesTheSameActionAsThePrompt () = + let doorPixel : PixelPosition = + { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) } + let world = Sim.initialWorldN 42UL 0 |> fun value -> { value with Avatar = { value.Avatar with Pos = ({ X = doorPixel.X; Y = doorPixel.Y } : Vec2) } } + let prompt = InteractionResolver.resolve world Outside + + Assert.IsTrue(prompt.IsSome) + Assert.AreEqual<InteractionAction>(EnterHome(HomeId 1), prompt.Value.Action) + Assert.AreEqual<string>("E 进屋", prompt.Value.Text) + + let _, view = M5Interaction.apply Interact world M5Interaction.initial + Assert.AreEqual<HomeMode>(Inside(HomeId 1), view.HomeMode) + Assert.AreEqual<string option>(None, view.Prompt) + + [<TestMethod>] + member _.WallBlocksNpcPromptAndOpenPanelsDoNotAcceptWorldInput () = + let avatar : Vec2 = { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2); Y = float32 (24 * Sim.tilePixels + Sim.tilePixels / 2) } + let npcPosition : Vec2 = { X = avatar.X - 40.0f; Y = avatar.Y - 80.0f } + let world = Sim.initialWorldN 42UL 1 + let npc = { world.Npcs.[0] with Pos = npcPosition } + let blockedWorld = { world with Avatar = { world.Avatar with Pos = avatar }; Npcs = [| npc |] } + + Assert.IsFalse(InteractionResolver.hasClearPath avatar npcPosition) + Assert.IsFalse(M5Interaction.worldInputAllowed { M5Interaction.initial with Panel = DialoguePanel }) + Assert.IsTrue(M5Interaction.worldInputAllowed M5Interaction.initial) + Assert.IsTrue(M5Interaction.resolveAction blockedWorld Outside |> Option.isNone) + + [<TestMethod>] + member _.WorldPanelDoesNotRenderA永久KeyList () = + let lines = M5Interaction.panelLines (Sim.initialWorldN 42UL 0) M5Interaction.initial + Assert.IsFalse(lines |> List.exists (fun line -> line = "Q 观察" || line = "TAB 需求" || line = "C 年鉴" || line = "L 关系")) + + [<TestMethod>] + member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () = + Assert.AreEqual<int>(188, characters.Length) + CollectionAssert.AreEqual(characters, characters |> Array.sort) + Assert.AreEqual<int>(characters.Length, characters |> Array.distinct |> Array.length) + Assert.AreEqual<int>(16, columns) + Assert.AreEqual<int>(24, cellPixels) + Assert.AreEqual<int>(12, rowCount) + Assert.AreEqual<int>(384, pixelWidth) + Assert.AreEqual<int>(288, pixelHeight) + Assert.AreEqual<string>("Assets/cjk-glyph-atlas.png", assetRelativePath) + Assert.AreEqual<string>("Assets/cjk-glyphs.txt", manifestRelativePath) + + [<TestMethod>] + member _.CjkGlyphAtlasMatchesThePackagedManifestAndRejectsUnknownCharacters () = + Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall (fun label -> label |> Seq.forall (fun character -> character = ' ' || not (Char.IsLetter character) || contains character))) + Assert.IsTrue(contains '江') + Assert.IsTrue(contains '龘' |> not) + Assert.AreEqual<int>(0, sourceRectangle '龘' |> Option.toList |> List.length) + let manifestPath = runtimeManifestPath () + Assert.IsTrue(File.Exists manifestPath, sprintf "Missing atlas manifest: %s" manifestPath) + let manifestLines = File.ReadAllLines manifestPath + Assert.AreEqual<int>(characters.Length, manifestLines.Length) + Assert.IsTrue(manifestLines |> Array.forall (fun line -> line.Length = 1), "The CJK manifest must contain exactly one character per line.") + CollectionAssert.AreEqual(characters, manifestLines |> Array.map (fun line -> line.[0])) + Assert.IsTrue(File.Exists(runtimeAssetPath ()), sprintf "Missing atlas PNG: %s" (runtimeAssetPath ())) diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png Binary files differnew file mode 100644 index 0000000..acea5a9 --- /dev/null +++ b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt new file mode 100644 index 0000000..6dae702 --- /dev/null +++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt @@ -0,0 +1,188 @@ +一 +不 +主 +乐 +乡 +买 +于 +互 +交 +人 +从 +价 +传 +作 +使 +保 +倍 +停 +入 +关 +内 +准 +出 +到 +前 +力 +功 +动 +助 +单 +卖 +南 +原 +友 +取 +可 +同 +和 +回 +围 +圃 +在 +墙 +备 +夜 +大 +天 +失 +好 +始 +存 +完 +家 +察 +对 +就 +层 +居 +已 +年 +庄 +库 +应 +度 +开 +式 +当 +录 +得 +心 +必 +态 +怒 +总 +恼 +意 +戏 +成 +或 +打 +找 +报 +拒 +择 +挑 +据 +提 +播 +操 +效 +敌 +数 +新 +方 +无 +时 +明 +易 +是 +晚 +景 +暂 +更 +最 +有 +村 +板 +林 +标 +档 +桥 +歉 +民 +水 +求 +江 +没 +法 +游 +物 +状 +瓦 +用 +留 +白 +目 +石 +确 +社 +离 +移 +竹 +笑 +精 +系 +紧 +给 +绝 +继 +绪 +续 +置 +聊 +能 +范 +获 +菜 +衅 +观 +认 +讨 +议 +记 +讲 +设 +话 +说 +请 +读 +谅 +败 +资 +足 +近 +还 +进 +迫 +退 +选 +速 +道 +量 +金 +鉴 +钱 +闭 +闲 +闻 +附 +零 +需 +面 +项 +须 +食 +饥 +饿 +黛 diff --git a/src/LivingVillage.Desktop/ChineseText.fs b/src/LivingVillage.Desktop/ChineseText.fs index b0c2fc6..9990cb3 100644 --- a/src/LivingVillage.Desktop/ChineseText.fs +++ b/src/LivingVillage.Desktop/ChineseText.fs @@ -64,6 +64,59 @@ module ChineseText = { SceneTitle = "江南水乡" Ready = "准备就绪" } + let requiredUiLabels : string list = + [ "江南水乡" + "水道" + "白墙黛瓦民居" + "石桥" + "竹林" + "菜圃" + "白天" + "夜晚" + "主菜单" + "设置" + "操作说明" + "状态" + "准备就绪" + "已暂停" + "继续游戏" + "开始新游戏" + "读取存档" + "退出" + "移动" + "互动" + "对话" + "观察" + "需求" + "年鉴" + "关系" + "暂停" + "速度" + "保存" + "读取" + "菜单或关闭" + "选择" + "确认" + "村庄" + "饥饿" + "精力" + "社交" + "金钱" + "最紧迫" + "无记录" + "交易" + "交易成功" + "交易失败" + "报价" + "总价" + "进入民居" + "离开民居" + "民居内景" + "请选择对话方式" + "对话完成" + "对话失败" + "年鉴已打开" ] + let layoutText (maxWidth: int) (supported: Set<char>) (text: string) : TextLayout = let width = max 1 maxWidth let normalized, missing = diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs new file mode 100644 index 0000000..e21f025 --- /dev/null +++ b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs @@ -0,0 +1,55 @@ +namespace LivingVillage.Desktop + +open System +open System.IO +open Microsoft.Xna.Framework +open Microsoft.Xna.Framework.Graphics + +module CjkGlyphAtlas = + let columns = 16 + let cellPixels = 24 + + // Keep this table sorted and stable so the generated PNG has a reproducible layout. + let private characterTable = + "一不主乐乡买于互交人从价传作使保倍停入关内准出到前力功动助单卖南原友取可同和回围圃在墙备夜大天失好始存完家察对就层居已年庄库应度开式当录得心必态怒总恼意戏成或打找报拒择挑据提播操效敌数新方无时明易是晚景暂更最有村板林标档桥歉民水求江没法游物状瓦用留白目石确社离移竹笑精系紧给绝继绪续置聊能范获菜衅观认讨议记讲设话说请读谅败资足近还进迫退选速道量金鉴钱闭闲闻附零需面项须食饥饿黛" + + let characters = characterTable.ToCharArray() + let rowCount = (characters.Length + columns - 1) / columns + let pixelWidth = columns * cellPixels + let pixelHeight = rowCount * cellPixels + let assetRelativePath = "Assets/cjk-glyph-atlas.png" + let manifestRelativePath = "Assets/cjk-glyphs.txt" + + let private indices = + characters + |> Array.mapi (fun index character -> character, index) + |> Map.ofArray + + let contains (character: char) = Map.containsKey character indices + + let sourceRectangle (character: char) = + indices + |> Map.tryFind character + |> Option.map (fun index -> + Rectangle( + (index % columns) * cellPixels, + (index / columns) * cellPixels, + cellPixels, + cellPixels)) + + let runtimeAssetPath () = Path.Combine(AppContext.BaseDirectory, assetRelativePath) + + let runtimeManifestPath () = Path.Combine(AppContext.BaseDirectory, manifestRelativePath) + + let load (device: GraphicsDevice) : Texture2D = + let path = runtimeAssetPath () + if not (File.Exists path) then + raise (FileNotFoundException(sprintf "CJK glyph atlas is missing: %s" path, path)) + + use stream = File.OpenRead path + let texture = Texture2D.FromStream(device, stream) + if texture.Width <> pixelWidth || texture.Height <> pixelHeight then + texture.Dispose() + invalidOp (sprintf "CJK glyph atlas has dimensions %dx%d; expected %dx%d." texture.Width texture.Height pixelWidth pixelHeight) + else + texture diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 5465ba2..dd04e26 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -6,71 +6,8 @@ open Microsoft.Xna.Framework.Graphics open Microsoft.Xna.Framework.Input open LivingVillage.Kernel open LivingVillage.Kernel.Sim - -type TileKind = - | Grass = 0 - | Path = 1 - | Water = 2 - | Flower = 3 - -module MapGen = - let generate (width: int) (height: int) : TileKind[,] = - let rnd = Random(20260918) - let map = Array2D.zeroCreate<TileKind> width height - for y in 0 .. height - 1 do - for x in 0 .. width - 1 do - let border = x = 0 || y = 0 || x = width - 1 || y = height - 1 - map.[x, y] <- - if border then TileKind.Water - elif rnd.NextDouble() < 0.04 then TileKind.Flower - else TileKind.Grass - let midX = width / 2 - let midY = height / 2 - for x in 1 .. width - 2 do - map.[x, midY] <- TileKind.Path - for y in 1 .. height - 2 do - map.[midX, y] <- TileKind.Path - map - -module Atlas = - let tilePixels = 32 - - let build (device: GraphicsDevice) : Texture2D = - let rnd = Random(7) - let c (r: int) (g: int) (b: int) = Color(r, g, b) - let tex = new Texture2D(device, tilePixels * 4, tilePixels) - let colors = Array.zeroCreate<Color> (tilePixels * tilePixels * 4) - for t in 0 .. 3 do - let baseColor, variance, isFlower = - match t with - | 1 -> (c 148 118 84), 12, false - | 2 -> (c 52 92 168), 10, false - | 3 -> (c 56 118 48), 14, true - | _ -> (c 56 118 48), 14, false - for y in 0 .. tilePixels - 1 do - for x in 0 .. tilePixels - 1 do - let jitter = rnd.Next(variance + 1) - variance / 2 - colors.[t * tilePixels * tilePixels + y * tilePixels + x] <- - c (int baseColor.R + jitter) (int baseColor.G + jitter) (int baseColor.B + jitter) - if isFlower then - for _ in 1 .. 6 do - let fx = rnd.Next(tilePixels - 2) - let fy = rnd.Next(tilePixels - 2) - let fc = if rnd.NextDouble() < 0.5 then c 240 220 80 else c 235 240 245 - for dy in 0 .. 1 do - for dx in 0 .. 1 do - colors.[t * tilePixels * tilePixels + (fy + dy) * tilePixels + fx + dx] <- fc - tex.SetData(colors) - tex - -module NpcView = - let actionName (k: NpcActionKind) : string = - match k with - | Eat -> "eat" - | Sleep -> "sleep" - | Wander -> "wander" - | Work -> "work" - | Chat -> "chat" +open LivingVillage.Desktop.ChineseText +open LivingVillage.Desktop.VillagePresentation module PixelText = let private glyphs = @@ -110,29 +47,49 @@ module PixelText = '7', [| "11111"; "00001"; "00010"; "00100"; "01000"; "01000"; "01000" |] '8', [| "01110"; "10001"; "10001"; "01110"; "10001"; "10001"; "01110" |] '9', [| "01110"; "10001"; "10001"; "01111"; "00001"; "00001"; "01110" |] - '-', [| "00000"; "00000"; "00000"; "11111"; "00000"; "00000"; "00000" |] - '.', [| "00000"; "00000"; "00000"; "00000"; "00000"; "00110"; "00110" |] - '=', [| "00000"; "11111"; "00000"; "00000"; "11111"; "00000"; "00000" |] - ':', [| "00000"; "00110"; "00110"; "00000"; "00110"; "00110"; "00000" |] - ';', [| "00000"; "00110"; "00110"; "00000"; "00110"; "00100"; "01000" |] - '/', [| "00001"; "00010"; "00010"; "00100"; "01000"; "01000"; "10000" |] - '>', [| "10000"; "01000"; "00100"; "00010"; "00100"; "01000"; "10000" |] - '?', [| "01110"; "10001"; "00001"; "00010"; "00100"; "00000"; "00100" |] ] - |> Map.ofList + ('-', [| "00000"; "00000"; "00000"; "11111"; "00000"; "00000"; "00000" |]) + ('.', [| "00000"; "00000"; "00000"; "00000"; "00000"; "00110"; "00110" |]) + ('=', [| "00000"; "11111"; "00000"; "00000"; "11111"; "00000"; "00000" |]) + (':', [| "00000"; "00110"; "00110"; "00000"; "00110"; "00110"; "00000" |]) + (';', [| "00000"; "00110"; "00110"; "00000"; "00110"; "00100"; "01000" |]) + ('/', [| "00001"; "00010"; "00010"; "00100"; "01000"; "01000"; "10000" |]) + ('>', [| "10000"; "01000"; "00100"; "00010"; "00100"; "01000"; "10000" |]) + ('?', [| "01110"; "10001"; "00001"; "00010"; "00100"; "00000"; "00100" |]) ] + |> Map.ofList + + let isRenderable (text: string) : bool = + not (isNull text) + && text + |> Seq.forall (fun character -> + character = ' ' + || Map.containsKey (Char.ToUpperInvariant character) glyphs + || CjkGlyphAtlas.contains character) - let draw (spriteBatch: SpriteBatch) (pixel: Texture2D) (x: int) (y: int) (scale: int) (color: Color) (text: string) = - let fallback = glyphs.['?'] + let draw (spriteBatch: SpriteBatch) (pixel: Texture2D) (cjkAtlas: Texture2D) (x: int) (y: int) (scale: int) (color: Color) (text: string) = let mutable cursor = x for character in text.ToUpperInvariant() do if character = ' ' then cursor <- cursor + (4 * scale) + elif CjkGlyphAtlas.sourceRectangle character |> Option.isSome then + let source = CjkGlyphAtlas.sourceRectangle character |> Option.get + let glyphPixels = 8 * scale + spriteBatch.Draw( + cjkAtlas, + Rectangle(cursor, y, glyphPixels, glyphPixels), + Nullable<Rectangle>(source), + color) + cursor <- cursor + glyphPixels else - let rows = glyphs |> Map.tryFind character |> Option.defaultValue fallback - for row in 0 .. rows.Length - 1 do - for column in 0 .. rows.[row].Length - 1 do - if rows.[row].[column] = '1' then - spriteBatch.Draw(pixel, Rectangle(cursor + column * scale, y + row * scale, scale, scale), color) - cursor <- cursor + (6 * scale) + match glyphs |> Map.tryFind character with + | Some rows -> + for row in 0 .. rows.Length - 1 do + for column in 0 .. rows.[row].Length - 1 do + if rows.[row].[column] = '1' then + spriteBatch.Draw(pixel, Rectangle(cursor + column * scale, y + row * scale, scale, scale), color) + cursor <- cursor + (6 * scale) + | None -> + // Unsupported characters are intentionally blank, never a fake glyph. + cursor <- cursor + (6 * scale) type LivingVillageGame() as this = inherit Game() @@ -140,7 +97,6 @@ type LivingVillageGame() as this = let autoplay = Environment.GetEnvironmentVariable("LV_AUTOPLAY") = "1" let autoplayFlow = Environment.GetEnvironmentVariable("LV_AUTOPLAY_FLOW") = "1" let automated = autoplay || autoplayFlow - let titleSuffix = if automated then " [AUTOPLAY]" else "" let modeName = if autoplayFlow then "autoplay-flow" elif autoplay then "autoplay" @@ -160,11 +116,13 @@ type LivingVillageGame() as this = | path -> path let graphics = new GraphicsDeviceManager(this) let mutable spriteBatch = Unchecked.defaultof<SpriteBatch> - let mutable atlas = Unchecked.defaultof<Texture2D> + let mutable artTextures = Unchecked.defaultof<VillageArt.ArtTextures> + let mutable cjkAtlas = Unchecked.defaultof<Texture2D> let mutable pixel = Unchecked.defaultof<Texture2D> - let mutable map = MapGen.generate Sim.mapWidthTiles Sim.mapHeightTiles + let renderPlan = VillageArt.sampleRenderPlan () let mutable world = Sim.initialWorldN 42UL Sim.npcCount let mutable camera = Vector2.Zero + let mutable avatarFacing = VillageArt.SouthFacing let mutable fpsFrames = 0 let mutable fpsSeconds = 0.0 let mutable prevKb = Unchecked.defaultof<KeyboardState> @@ -183,7 +141,7 @@ type LivingVillageGame() as this = graphics.SynchronizeWithVerticalRetrace <- true this.IsFixedTimeStep <- true this.TargetElapsedTime <- TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 60L) - this.Window.Title <- $"Living Village M6{titleSuffix}" + this.Window.Title <- ChineseText.copy.SceneTitle printfn $"mode={modeName} seed=42" printfn "controls=WASD move E interact 1-6 choose Q observe Tab needs C chronicle L relations P pause F1/F2/F3 speed F6 save F7 load Esc close/exit" member private this.CenterCamera() = @@ -200,7 +158,8 @@ type LivingVillageGame() as this = member private this.ResetWorldView() = relationView <- false relationSnapshot <- None - m5View <- M5Interaction.initial + avatarFacing <- VillageArt.SouthFacing + m5View <- M5Interaction.refreshPrompt world M5Interaction.initial this.CenterCamera() member private this.StartNewGame() = @@ -212,10 +171,12 @@ type LivingVillageGame() as this = member private this.SaveWorld (prefix: string) : bool = try WorldSave.saveToFile savePath world + m5View <- { m5View with Status = "save succeeded" } printfn $"{prefix}=ok path={savePath} tick={world.Tick}" true with | ex -> + m5View <- { m5View with Status = "save failed" } printfn $"{prefix}=error path={savePath} message={ex.Message}" false @@ -326,19 +287,19 @@ type LivingVillageGame() as this = this.OpenMainMenu() leftGame <- true if not loadFailed && not leftGame then - if lPressed then + if lPressed && M5Interaction.worldInputAllowed m5View then relationView <- not relationView if relationView then relationSnapshot <- Some(Sim.relationMatrix world) let state = if relationView then "on" else "off" printfn $"relation view={state} tick={world.Tick}" let m5Command = - if pressed Keys.E then Some Interact - elif pressedAny [ Keys.D1; Keys.NumPad1 ] then Some Intent1 - elif pressedAny [ Keys.D2; Keys.NumPad2 ] then Some Intent2 - elif pressedAny [ Keys.D3; Keys.NumPad3 ] then Some Intent3 - elif pressedAny [ Keys.D4; Keys.NumPad4 ] then Some Intent4 - elif pressedAny [ Keys.D5; Keys.NumPad5 ] then Some Intent5 - elif pressedAny [ Keys.D6; Keys.NumPad6 ] then Some Intent6 + if M5Interaction.worldInputAllowed m5View && pressed Keys.E then Some Interact + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D1; Keys.NumPad1 ] then Some Intent1 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D2; Keys.NumPad2 ] then Some Intent2 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D3; Keys.NumPad3 ] then Some Intent3 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D4; Keys.NumPad4 ] then Some Intent4 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D5; Keys.NumPad5 ] then Some Intent5 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D6; Keys.NumPad6 ] then Some Intent6 elif pressed Keys.Q then Some Observe elif pressed Keys.Tab then Some ToggleNeeds elif pressed Keys.C then Some ShowChronicle @@ -370,8 +331,11 @@ type LivingVillageGame() as this = let stepCount = SimulationControl.stepsPerFrameWithLegacy legacyStepsPerFrame simulationControl if stepCount > 0 then for _ in 1 .. stepCount do + let previousPosition = world.Avatar.Pos world <- Sim.step ts world + avatarFacing <- VillageArt.facingAfterMovement previousPosition world.Avatar.Pos avatarFacing this.CenterCamera() + m5View <- M5Interaction.refreshPrompt world m5View member private this.RunAutoplayFlow() = let moveDown count = @@ -423,10 +387,18 @@ type LivingVillageGame() as this = override _.LoadContent() = spriteBatch <- new SpriteBatch(this.GraphicsDevice) - atlas <- Atlas.build this.GraphicsDevice + artTextures <- VillageArt.buildTextures this.GraphicsDevice + cjkAtlas <- CjkGlyphAtlas.load this.GraphicsDevice pixel <- new Texture2D(this.GraphicsDevice, 1, 1) pixel.SetData([| Color.White |]) + override _.UnloadContent() = + if not (isNull artTextures.TileAtlas) then artTextures.TileAtlas.Dispose() + if not (isNull artTextures.CharacterAtlas) then artTextures.CharacterAtlas.Dispose() + if not (isNull cjkAtlas) then cjkAtlas.Dispose() + if not (isNull pixel) then pixel.Dispose() + if not (isNull spriteBatch) then spriteBatch.Dispose() + override this.Update(gameTime: GameTime) = let kb = Keyboard.GetState() let pressed (key: Keys) = kb.IsKeyDown(key) && not (prevKb.IsKeyDown(key)) @@ -443,25 +415,6 @@ type LivingVillageGame() as this = let fps = float fpsFrames / fpsSeconds fpsFrames <- 0 fpsSeconds <- 0.0 - let npcAction = - if world.Npcs.Length > 0 then - NpcView.actionName world.Npcs.[0].Mind.Action - else "none" - let profile = M6Presentation.profileAtTick world.Tick - let dayNight = - match profile.Mode with - | Day -> "DAY" - | Night -> "NIGHT" - let viewTag = if relationView then " | [RELATIONS]" else "" - let pageName = - match menu.Page with - | MainMenu -> "MAIN MENU" - | Settings -> "SETTINGS" - | Controls -> "CONTROLS" - | Playing -> "PLAYING" - | LoadError -> "LOAD ERROR" - this.Window.Title <- - $"Living Village M6{titleSuffix} | {pageName} | fps {fps:F1} | tick {world.Tick} | {dayNight} | {M6Presentation.clockLabel simulationControl} | npc {npcAction}{viewTag} | panel {M5Interaction.panelName m5View} | {M5Interaction.titleText m5View}" printfn $"fps={fps:F1} tick={world.Tick} pos=({world.Avatar.Pos.X:F0},{world.Avatar.Pos.Y:F0})" prevKb <- kb @@ -529,7 +482,7 @@ type LivingVillageGame() as this = let textColor = Color(210, 226, 220) let mutedColor = Color(140, 170, 170) let drawText (x: int) (y: int) (color: Color) (text: string) = - PixelText.draw spriteBatch pixel x y textScale color text + PixelText.draw spriteBatch pixel cjkAtlas x y textScale color text let drawSelection (index: int) (label: string) (y: int) = let selected = index = menu.Selected let prefix = if selected then ">" else " " @@ -538,62 +491,59 @@ type LivingVillageGame() as this = spriteBatch.Draw(pixel, Rectangle(0, 0, width, height), Color(4, 9, 14, 190)) spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, panelHeight), Color(11, 19, 27, 245)) spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 6), Color(92, 180, 190)) - drawText innerX (panelY + 28) (Color(235, 246, 232)) "LIVING VILLAGE" + drawText innerX (panelY + 28) (Color(235, 246, 232)) ChineseText.copy.SceneTitle let contentY = panelY + 100 match menu.Page with | MainMenu -> - drawText innerX (panelY + 70) mutedColor "MAIN MENU" + drawText innerX (panelY + 70) mutedColor (ChineseText.menuHeading MainMenuHeading) MenuState.items menu |> List.iteri (fun index item -> drawSelection index (MenuState.itemLabel item) (contentY + index * lineHeight)) - drawText innerX (panelY + panelHeight - 54) mutedColor "UP DOWN SELECT ENTER CONFIRM" + drawText innerX (panelY + panelHeight - 54) mutedColor "UP DOWN 选择 ENTER 确认" | Settings -> - drawText innerX (panelY + 70) mutedColor "SETTINGS" + drawText innerX (panelY + 70) mutedColor (ChineseText.menuHeading SettingsHeading) MenuState.settings |> List.iteri (fun index setting -> drawSelection index (MenuState.settingsLabel setting) (contentY + index * lineHeight)) - drawText innerX (panelY + panelHeight - 84) mutedColor (sprintf "ACTIVE %s" (M6Presentation.clockLabel menu.Settings)) - drawText innerX (panelY + panelHeight - 54) mutedColor "ENTER APPLY ESC BACK" + drawText innerX (panelY + panelHeight - 84) mutedColor (sprintf "当前 %s" (M6Presentation.clockLabel menu.Settings)) + drawText innerX (panelY + panelHeight - 54) mutedColor "ENTER 应用 ESC 返回" | Controls -> - drawText innerX (panelY + 70) mutedColor "CONTROLS" + drawText innerX (panelY + 70) mutedColor (ChineseText.menuHeading ControlsHeading) MenuState.controlRows |> List.iteri (fun index row -> drawText innerX (contentY + index * 25) textColor (sprintf "%s %s" row.Keys row.Action)) - drawText innerX (panelY + panelHeight - 28) mutedColor "ESC BACK" + drawText innerX (panelY + panelHeight - 28) mutedColor "ESC 返回" | LoadError -> - drawText innerX (panelY + 70) (Color(240, 130, 120)) "LOAD ERROR" - let message = menu.Error |> Option.defaultValue "NO SAVE AVAILABLE" |> fun value -> value.ToUpperInvariant() + drawText innerX (panelY + 70) (Color(240, 130, 120)) "读取失败" + let message = menu.Error |> Option.defaultValue "暂无存档" let visible = if message.Length > 34 then message.Substring(0, 34) else message drawText innerX contentY textColor visible - drawText innerX (contentY + lineHeight * 2) mutedColor "ENTER OR ESC BACK" + drawText innerX (contentY + lineHeight * 2) mutedColor "ENTER ESC 返回" | Playing -> () spriteBatch.End() member private this.DrawWorldView() = let profile = M6Presentation.profileAtTick world.Tick - this.GraphicsDevice.Clear(profile.Background) let viewport = this.GraphicsDevice.Viewport - let camX = int camera.X - let camY = int camera.Y - let tx0 = max 0 (camX / Sim.tilePixels) - let ty0 = max 0 (camY / Sim.tilePixels) - let tx1 = min (Sim.mapWidthTiles - 1) ((camX + viewport.Width) / Sim.tilePixels + 1) - let ty1 = min (Sim.mapHeightTiles - 1) ((camY + viewport.Height) / Sim.tilePixels + 1) + this.GraphicsDevice.Clear(profile.Background) spriteBatch.Begin() - for y in ty0 .. ty1 do - for x in tx0 .. tx1 do - let src = Rectangle(int map.[x, y] * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels) - let dst = - Rectangle(x * Sim.tilePixels - camX, y * Sim.tilePixels - camY, Sim.tilePixels, Sim.tilePixels) - spriteBatch.Draw(atlas, dst, src, profile.WorldTint) - let avatarDst = - Rectangle(int world.Avatar.Pos.X - camX, int world.Avatar.Pos.Y - camY, Sim.tilePixels, Sim.tilePixels) - spriteBatch.Draw(pixel, avatarDst, Color.Red) - let npcShades = - [| Color.Navy; Color.MediumBlue; Color.Blue; Color.RoyalBlue; Color.DodgerBlue |] - for npc in world.Npcs do - let npcDst = - Rectangle(int npc.Pos.X - camX, int npc.Pos.Y - camY, Sim.tilePixels, Sim.tilePixels) - let (NpcId idx) = npc.Id - spriteBatch.Draw(pixel, npcDst, npcShades.[idx % npcShades.Length]) + match m5View.HomeMode with + | Inside _ -> + VillageArt.drawInterior spriteBatch pixel viewport.Width viewport.Height renderPlan.Interior + | Outside -> + VillageArt.drawWorld + spriteBatch + artTextures + camera + viewport.Width + viewport.Height + renderPlan + profile.WorldTint + + let avatarSpec = VillageArt.npcSpriteSpec (NpcId 0) avatarFacing world.Tick + VillageArt.drawCharacter spriteBatch artTextures camera world.Avatar.Pos avatarSpec profile.WorldTint + + for npc in world.Npcs do + let spec = VillageArt.npcSpriteSpecAtTarget npc.Id npc.Pos npc.Mind.Target world.Tick + VillageArt.drawCharacter spriteBatch artTextures camera npc.Pos spec profile.WorldTint spriteBatch.End() this.DrawM5Overlay() @@ -611,14 +561,7 @@ type LivingVillageGame() as this = let maxChars = max 1 (innerWidth / (6 * textScale)) let maxLines = max 1 ((panelHeight - 32) / lineHeight) let profile = M6Presentation.profileAtTick world.Tick - let dayNight = - match profile.Mode with - | Day -> "DAY" - | Night -> "NIGHT" - let m6Lines = - [ sprintf "TIME %s %s" dayNight (M6Presentation.clockLabel simulationControl) - "P PAUSE F1 F2 F3 SPEED" - "F6 SAVE F7 LOAD" ] + let m6Lines = [ sprintf "时间 %s %s" (M6Presentation.dayNightLabel profile.Mode) (M6Presentation.clockLabel simulationControl) ] let lines = (m6Lines @ M5Interaction.panelLines world m5View) |> List.truncate maxLines spriteBatch.Begin() spriteBatch.Draw(pixel, panel, Color(15, 19, 25, 220)) @@ -627,5 +570,5 @@ type LivingVillageGame() as this = |> List.iteri (fun index line -> let visible = if line.Length > maxChars then line.Substring(0, maxChars) else line let color = if index = 0 then Color(220, 235, 220) else Color(190, 205, 210) - PixelText.draw spriteBatch pixel innerX (panelY + 12 + index * lineHeight) textScale color visible) + PixelText.draw spriteBatch pixel cjkAtlas innerX (panelY + 12 + index * lineHeight) textScale color visible) spriteBatch.End() diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs index da78b2e..58ebdd4 100644 --- a/src/LivingVillage.Desktop/Interaction.fs +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -3,6 +3,18 @@ namespace LivingVillage.Desktop open System open LivingVillage.Kernel open LivingVillage.Kernel.Sim +open LivingVillage.Desktop.VillagePresentation + +type InteractionAction = + | TalkTo of NpcId + | EnterHome of HomeId + | ExitHome of HomeId + +type InteractionTarget = + { Action: InteractionAction + Position: Vec2 + Text: string + DistanceSquared: float32 } type M5Panel = | WorldPanel @@ -30,7 +42,116 @@ type M5View = Needs: NeedsPanel option Observations: Observation list Chronicle: string - Status: string } + Status: string + HomeMode: HomeMode + Prompt: string option } + +module InteractionResolver = + + 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 private homeDoorPosition (home: Home) : Vec2 = + home.Door |> worldTileForSceneTile |> tileCenter + + let private blockingTiles : Set<int * int> = + scene.Elements + |> List.choose (fun element -> + match element.Prop with + | WhiteWallDarkTileHome -> + let tile = worldTileForSceneTile element.Position + Some(tile.X, tile.Y) + | _ -> None) + |> Set.ofList + + let private tileAt (position: Vec2) : int * int = + int (Math.Floor(float position.X / float Sim.tilePixels)), + int (Math.Floor(float position.Y / float Sim.tilePixels)) + + let hasClearPath (fromPosition: Vec2) (targetPosition: Vec2) : bool = + let dx = targetPosition.X - fromPosition.X + let dy = targetPosition.Y - fromPosition.Y + let distance = sqrt (dx * dx + dy * dy) + let steps = max 1 (int (Math.Ceiling(float (distance / float32 Sim.tilePixels * 4.0f)))) + [ 1 .. steps - 1 ] + |> List.forall (fun step -> + let fraction = float32 step / float32 steps + let sample : Vec2 = + { X = fromPosition.X + dx * fraction + Y = fromPosition.Y + dy * fraction } + not (Set.contains (tileAt sample) blockingTiles)) + + let private distanceSquared (first: Vec2) (second: Vec2) : float32 = + let dx = second.X - first.X + let dy = second.Y - first.Y + dx * dx + dy * dy + + let private dialogueAvailable (npc: Npc) : bool = + npc.Mind.Action <> Sleep && npc.Mind.Action <> Chat + + let private npcNumber (NpcId id) = id + + let private target action position text avatarPosition : InteractionTarget = + { Action = action + Position = position + Text = text + DistanceSquared = distanceSquared avatarPosition position } + + let private targetIsReachable (avatarPosition: Vec2) (position: Vec2) : bool = + distanceSquared avatarPosition position <= Sim.chatRangeSq + && hasClearPath avatarPosition position + + let resolve (world: World) (homeMode: HomeMode) : InteractionTarget option = + let avatarPosition = world.Avatar.Pos + let candidates = + match homeMode with + | Inside homeId -> + scene.Homes + |> List.choose (fun home -> + if home.Id = homeId then + let position = homeDoorPosition home + let avatarTile = + sceneTileFromPixel { X = avatarPosition.X; Y = avatarPosition.Y } + if isNearHomeDoor avatarTile home && targetIsReachable avatarPosition position then + Some(target (ExitHome home.Id) position "E 出屋" avatarPosition) + else None + else None) + | Outside -> + let homes = + scene.Homes + |> List.choose (fun home -> + let position = homeDoorPosition home + let avatarTile = + sceneTileFromPixel { X = avatarPosition.X; Y = avatarPosition.Y } + if isNearHomeDoor avatarTile home && targetIsReachable avatarPosition position then + Some(target (EnterHome home.Id) position "E 进屋" avatarPosition) + else None) + let npcs = + world.Npcs + |> Array.choose (fun npc -> + if dialogueAvailable npc && targetIsReachable avatarPosition npc.Pos then + Some(target (TalkTo npc.Id) npc.Pos "E 交谈" avatarPosition) + else None) + |> Array.toList + homes @ npcs + + candidates + |> List.sortBy (fun candidate -> + let priority = + match candidate.Action with + | EnterHome _ + | ExitHome _ -> 0 + | TalkTo _ -> 1 + let stableId = + match candidate.Action with + | TalkTo id -> npcNumber id + | EnterHome(HomeId id) + | ExitHome(HomeId id) -> id + priority, candidate.DistanceSquared, stableId) + |> List.tryHead + + let resolveAction = resolve module M5Interaction = @@ -40,7 +161,21 @@ module M5Interaction = Needs = None Observations = [] Chronicle = "" - Status = "ready" } + Status = "ready" + HomeMode = Outside + Prompt = None } + + let worldInputAllowed (view: M5View) : bool = + view.Panel = WorldPanel + + let refreshPrompt (world: World) (view: M5View) : M5View = + if worldInputAllowed view then + { view with Prompt = InteractionResolver.resolve world view.HomeMode |> Option.map (fun target -> target.Text) } + else + { view with Prompt = None } + + let resolveAction (world: World) (homeMode: HomeMode) : InteractionTarget option = + InteractionResolver.resolveAction world homeMode let private observationBounds (world: World) : VisibleBounds = let radius = Sim.chatRangePx @@ -106,13 +241,14 @@ module M5Interaction = match List.tryItem index menu.Options with | None -> world, { view with Status = "invalid dialogue option" } | Some intent -> - match Sim.chooseDialogue menu.Target intent world with + match Sim.chooseDialogue menu.Target intent world with | DialogueSucceeded(outcome, next) -> next, { view with Panel = DialoguePanel Menu = None Chronicle = Sim.annalText next + Prompt = None Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response } | DialogueRejected(failure, next) -> next, { view with Status = sprintf "dialogue rejected: %s" (dialogueFailureName failure) } @@ -120,14 +256,42 @@ module M5Interaction = let apply (command: M5Command) (world: World) (view: M5View) : World * M5View = match command with | Interact -> - match Sim.openDialogueMenu world with - | Some menu -> + match InteractionResolver.resolve world view.HomeMode with + | Some { Action = TalkTo target } -> + let menu = { Target = target; Options = Sim.dialogueOptions } world, { view with Panel = DialoguePanel Menu = Some menu + Prompt = None Status = menuStatus menu } - | None -> world, { view with Panel = WorldPanel; Menu = None; Status = "no nearby NPC available" } + | Some { Action = EnterHome _ } -> + let pixel = { PixelPosition.X = world.Avatar.Pos.X; Y = world.Avatar.Pos.Y } + let scenePosition = sceneTileFromPixel pixel + match enterHome scenePosition view.HomeMode with + | Ok nextMode -> + world, + { view with + Panel = WorldPanel + Menu = None + HomeMode = nextMode + Prompt = None + Status = "home entered" } + | Error failure -> world, { view with Prompt = None; Status = sprintf "home entry rejected: %A" failure } + | Some { Action = ExitHome _ } -> + match exitHome view.HomeMode with + | Ok nextMode -> + world, + { view with + Panel = WorldPanel + Menu = None + HomeMode = nextMode + Prompt = None + Status = "home exited" } + | Error failure -> world, { view with Prompt = None; Status = sprintf "home exit rejected: %A" failure } + | None -> + world, + { view with Panel = WorldPanel; Menu = None; Prompt = None; Status = "no nearby interaction" } | Intent1 -> chooseIntent 0 world view | Intent2 -> chooseIntent 1 world view | Intent3 -> chooseIntent 2 world view @@ -137,26 +301,29 @@ module M5Interaction = | Observe -> let observations = Sim.observeVisible (observationBounds world) world world, - { view with - Panel = ObservationPanel - Observations = observations - Status = sprintf "observation visible_npcs=%d" observations.Length } + { view with + Panel = ObservationPanel + Observations = observations + Prompt = None + Status = sprintf "observation visible_npcs=%d" observations.Length } | ToggleNeeds -> if view.Panel = NeedsPanel then - world, { view with Panel = WorldPanel; Needs = None; Status = "needs panel closed" } + world, refreshPrompt world { view with Panel = WorldPanel; Needs = None; Status = "needs panel closed" } else world, { view with Panel = NeedsPanel Needs = Some(Sim.needsPanel world) + Prompt = None Status = "needs panel open" } | ShowChronicle -> world, { view with Panel = ChroniclePanel Chronicle = Sim.annalText world + Prompt = None Status = sprintf "chronicle entries=%d" world.Annals.Length } - | ClosePanel -> world, { view with Panel = WorldPanel; Menu = None; Status = "panel closed" } + | ClosePanel -> world, refreshPrompt world { view with Panel = WorldPanel; Menu = None; Status = "panel closed" } let tradeResultText (request: TradeRequest) (quote: float32 option) (result: TradeResult) : string = match result with @@ -181,7 +348,7 @@ module M5Interaction = let titleText (view: M5View) : string = match view.Panel with - | WorldPanel -> "E 互动 | Q 观察 | TAB 需求 | C 年鉴 | L 关系" + | WorldPanel -> "江南水乡" | NeedsPanel -> "需求 | TAB 关闭 | Q 观察 | C 年鉴" | ObservationPanel -> "观察 | TAB 需求 | C 年鉴 | ESC 关闭" | DialoguePanel -> @@ -201,6 +368,10 @@ module M5Interaction = | "needs panel open" -> "需求面板已打开" | "needs panel closed" -> "需求面板已关闭" | "panel closed" -> "面板已关闭" + | "home entered" -> "已进入民居" + | "home exited" -> "已离开民居" + | "save succeeded" -> "保存成功" + | "save failed" -> "保存失败" | value when value.StartsWith("dialogue target=") -> "请选择对话方式" | value when value.StartsWith("dialogue intent=") -> "对话完成" | value when value.StartsWith("dialogue rejected:") -> @@ -210,7 +381,7 @@ module M5Interaction = | value when value.StartsWith("chronicle") -> "年鉴已打开" | value when value.StartsWith("no dialogue") -> "当前没有可用对话" | value when value.StartsWith("invalid dialogue") -> "对话选项无效" - | value when value.StartsWith("no nearby") -> "附近没有可互动村民" + | value when value.StartsWith("no nearby") -> "附近没有可互动目标" | _ -> "状态已更新" let private annalLine (entry: AnnalEntry) : string = @@ -247,13 +418,9 @@ module M5Interaction = let status = if String.IsNullOrWhiteSpace view.Status then "准备就绪" else statusLabel view.Status match view.Panel with | WorldPanel -> - [ "村庄" - "E 互动" - "Q 观察" - "TAB 需求" - "C 年鉴" - "L 关系" - status ] + [ "村庄" ] + @ (view.Prompt |> Option.toList) + @ [ status ] | NeedsPanel -> match view.Needs with | None -> [ "需求"; "暂无数据"; status ] diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 171ba71..4a1b483 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -9,6 +9,7 @@ <Compile Include="VillagePresentation.fs" /> <Compile Include="VillageArt.fs" /> <Compile Include="ChineseText.fs" /> + <Compile Include="CjkGlyphAtlas.fs" /> <Compile Include="MenuState.fs" /> <Compile Include="Interaction.fs" /> <Compile Include="M6Presentation.fs" /> @@ -24,4 +25,9 @@ <ProjectReference Include="..\LivingVillage.Kernel\LivingVillage.Kernel.fsproj" /> </ItemGroup> + <ItemGroup> + <None Include="Assets\cjk-glyph-atlas.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + <None Include="Assets\cjk-glyphs.txt" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + </ItemGroup> + </Project> diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index 0be4786..e46d85c 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -1,5 +1,7 @@ namespace LivingVillage.Desktop +open Microsoft.Xna.Framework.Graphics +open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop.VillagePresentation @@ -27,6 +29,39 @@ module VillageArt = { Props: ArtProp list Tiles: ArtTile list } + type GroundKind = + | Water + | StonePath + + type RenderPropKind = + | House + | Bridge + | BambooGrove + | VegetablePatch + + type RenderProp = + { Kind: RenderPropKind + Position: TilePosition } + + type InteriorElementKind = + | Window + | Table + | Bed + | Screen + + type InteriorElement = + { Kind: InteriorElementKind + Position: TilePosition } + + type InteriorPlan = + { Elements: InteriorElement list } + + type JiangnanRenderPlan = + { Origin: TilePosition + Grounds: (TilePosition * GroundKind) list + Props: RenderProp list + Interior: InteriorPlan } + type NpcVisualVariant = | IndigoRobe | OchreVest @@ -47,6 +82,24 @@ module VillageArt = Direction: FacingDirection Frame: AnimationFrame } + type ArtTextures = + { TileAtlas: Texture2D + CharacterAtlas: Texture2D } + + type private TileSprite = + | GrassSprite + | WaterSprite + | StonePathSprite + | RoofSprite + | WallSprite + | BridgeSprite + | BambooSprite + | GardenSprite + + type private XnaColor = Microsoft.Xna.Framework.Color + type private XnaRectangle = Microsoft.Xna.Framework.Rectangle + type private XnaVector2 = Microsoft.Xna.Framework.Vector2 + let private position x y : TilePosition = { X = x Y = y } @@ -72,6 +125,52 @@ module VillageArt = tile StonePaving 5 5 tile StonePaving 6 5 ] } + let sampleRenderPlan () : JiangnanRenderPlan = + let ground kind x y = position x y, kind + let renderProp (kind: RenderPropKind) x y : RenderProp = + { Kind = kind + Position = position x y } + let interiorElement kind x y : InteriorElement = + { Kind = kind + Position = position x y } + + { Origin = position 25 18 + Grounds = + [ ground Water 10 2 + ground Water 10 3 + ground Water 10 4 + ground Water 10 5 + ground Water 10 6 + ground Water 10 7 + ground Water 11 4 + ground Water 11 5 + ground StonePath 3 5 + ground StonePath 4 5 + ground StonePath 5 5 + ground StonePath 6 5 + ground StonePath 7 5 + ground StonePath 8 5 + ground StonePath 9 5 + ground StonePath 10 5 + ground StonePath 11 5 + ground StonePath 6 6 + ground StonePath 6 7 ] + Props = + [ renderProp House 6 4 + renderProp Bridge 10 5 + renderProp BambooGrove 3 2 + renderProp VegetablePatch 8 7 ] + Interior = + { Elements = + [ interiorElement Window 2 2 + interiorElement Table 4 4 + interiorElement Bed 7 3 + interiorElement Screen 7 6 ] } } + + let worldTile (plan: JiangnanRenderPlan) (localPosition: TilePosition) : TilePosition = + { X = plan.Origin.X + localPosition.X + Y = plan.Origin.Y + localPosition.Y } + let private positiveModulo divisor value = let remainder = value % divisor if remainder < 0 then remainder + divisor else remainder @@ -89,7 +188,336 @@ module VillageArt = let animationFrame (tick: int64) : AnimationFrame = if positiveModulo64 8L tick < 4L then FrameOne else FrameTwo + let directionFromVector (vector: Vec2) : FacingDirection = + if abs vector.X < 0.001f && abs vector.Y < 0.001f then + SouthFacing + elif abs vector.X >= abs vector.Y then + if vector.X >= 0.0f then EastFacing else WestFacing + elif vector.Y >= 0.0f then + SouthFacing + else + NorthFacing + + let facingAfterMovement (previousPosition: Vec2) (nextPosition: Vec2) (current: FacingDirection) : FacingDirection = + let movement : Vec2 = + { X = nextPosition.X - previousPosition.X + Y = nextPosition.Y - previousPosition.Y } + if abs movement.X < 0.001f && abs movement.Y < 0.001f then current else directionFromVector movement + let npcSpriteSpec (npcId: NpcId) (direction: FacingDirection) (tick: int64) : NpcSpriteSpec = { Variant = npcVisualVariant npcId Direction = direction Frame = animationFrame tick } + + let npcSpriteSpecAtTarget (npcId: NpcId) (position: Vec2) (target: Vec2) (tick: int64) : NpcSpriteSpec = + let direction = + directionFromVector + { X = target.X - position.X + Y = target.Y - position.Y } + npcSpriteSpec npcId direction tick + + let characterSourceRectangle (spec: NpcSpriteSpec) : Microsoft.Xna.Framework.Rectangle = + let variantIndex = + match spec.Variant with + | IndigoRobe -> 0 + | OchreVest -> 1 + | JadeSash -> 2 + let directionIndex = + match spec.Direction with + | NorthFacing -> 0 + | SouthFacing -> 1 + | WestFacing -> 2 + | EastFacing -> 3 + let frameIndex = + match spec.Frame with + | FrameOne -> 0 + | FrameTwo -> 1 + let cellIndex = (variantIndex * 4 + directionIndex) * 2 + frameIndex + Microsoft.Xna.Framework.Rectangle(cellIndex * 32, 0, 32, 48) + + let private setPixel (pixels: XnaColor[]) (width: int) (x: int) (y: int) (color: XnaColor) = + let height = pixels.Length / width + if x >= 0 && x < width && y >= 0 && y < height then + pixels.[y * width + x] <- color + + let private fillRect (pixels: XnaColor[]) (width: int) (x: int) (y: int) (rectWidth: int) (rectHeight: int) (color: XnaColor) = + for row in y .. y + rectHeight - 1 do + for column in x .. x + rectWidth - 1 do + setPixel pixels width column row color + + let private rgba (r: int) (g: int) (b: int) (a: int) : XnaColor = XnaColor(r, g, b, a) + + let private buildTileAtlas (device: GraphicsDevice) : Texture2D = + let atlasWidth = Sim.tilePixels * 8 + let atlasHeight = Sim.tilePixels + let pixels = Array.create (atlasWidth * atlasHeight) (rgba 0 0 0 0) + let fillSlot slot color = fillRect pixels atlasWidth (slot * Sim.tilePixels) 0 Sim.tilePixels Sim.tilePixels color + let setSlotPixel slot x y color = setPixel pixels atlasWidth (slot * Sim.tilePixels + x) y color + let grass = rgba 66 112 70 255 + let grassLight = rgba 92 139 82 255 + let water = rgba 54 116 145 255 + let waterLight = rgba 105 174 184 255 + let path = rgba 170 153 123 255 + let pathLight = rgba 201 183 145 255 + let roof = rgba 45 49 57 255 + let roofLight = rgba 76 80 85 255 + let wall = rgba 225 216 195 255 + let wallShadow = rgba 166 147 119 255 + let wood = rgba 119 76 47 255 + let woodLight = rgba 164 111 61 255 + let bamboo = rgba 51 101 61 255 + let bambooLight = rgba 107 153 81 255 + let soil = rgba 122 78 49 255 + let sprout = rgba 86 139 68 255 + + fillSlot 0 grass + for y in 0 .. Sim.tilePixels - 1 do + for x in 0 .. Sim.tilePixels - 1 do + if (x * 13 + y * 7) % 29 = 0 then + setSlotPixel 0 x y grassLight + + fillSlot 1 water + for y in [ 5; 16; 27 ] do + for x in 3 .. Sim.tilePixels - 4 do + if (x + y) % 5 <> 0 then + setSlotPixel 1 x y waterLight + for x in [ 8; 23 ] do + fillRect pixels atlasWidth (x + Sim.tilePixels) 2 2 26 (rgba 44 93 124 255) + + fillSlot 2 path + for y in [ 0; 31 ] do + for x in 0 .. Sim.tilePixels - 1 do + setSlotPixel 2 x y pathLight + for x in [ 7; 24 ] do + for y in 4 .. 27 do + if y % 3 <> 0 then + setSlotPixel 2 x y pathLight + + fillSlot 3 roof + for y in [ 5; 13; 21; 29 ] do + for x in 0 .. Sim.tilePixels - 1 do + if (x + y) % 6 < 5 then + setSlotPixel 3 x y roofLight + + fillSlot 4 wall + fillRect pixels atlasWidth (4 * Sim.tilePixels) 0 2 Sim.tilePixels wallShadow + fillRect pixels atlasWidth (4 * Sim.tilePixels + 15) 0 2 Sim.tilePixels wallShadow + fillRect pixels atlasWidth (4 * Sim.tilePixels + 30) 0 2 Sim.tilePixels wallShadow + fillRect pixels atlasWidth (4 * Sim.tilePixels) 27 Sim.tilePixels 3 wallShadow + + fillSlot 5 wood + for y in [ 6; 15; 24 ] do + fillRect pixels atlasWidth (5 * Sim.tilePixels) y Sim.tilePixels 2 woodLight + + fillSlot 6 (rgba 0 0 0 0) + for stemX in [ 6; 16; 26 ] do + fillRect pixels atlasWidth (6 * Sim.tilePixels + stemX) 4 3 25 bamboo + for leafY in [ 8; 17; 25 ] do + fillRect pixels atlasWidth (6 * Sim.tilePixels + stemX - 4) leafY 5 2 bambooLight + fillRect pixels atlasWidth (6 * Sim.tilePixels + stemX + 2) (leafY + 2) 5 2 bambooLight + + fillSlot 7 (rgba 0 0 0 0) + for row in [ 6; 15; 24 ] do + fillRect pixels atlasWidth (7 * Sim.tilePixels + 2) row 28 3 soil + for sproutX in [ 6; 15; 24 ] do + fillRect pixels atlasWidth (7 * Sim.tilePixels + sproutX) (row - 3) 2 3 sprout + + let texture = new Texture2D(device, atlasWidth, atlasHeight, false, SurfaceFormat.Color) + texture.SetData pixels + texture + + let private buildCharacterAtlas (device: GraphicsDevice) : Texture2D = + let cellWidth = 32 + let cellHeight = 48 + let columns = 3 * 4 * 2 + let atlasWidth = columns * cellWidth + let pixels = Array.create (atlasWidth * cellHeight) (rgba 0 0 0 0) + let variants = [| IndigoRobe; OchreVest; JadeSash |] + let directions = [| NorthFacing; SouthFacing; WestFacing; EastFacing |] + let frames = [| FrameOne; FrameTwo |] + + for variantIndex in 0 .. variants.Length - 1 do + for directionIndex in 0 .. directions.Length - 1 do + for frameIndex in 0 .. frames.Length - 1 do + let variant = variants.[variantIndex] + let direction = directions.[directionIndex] + let frame = frames.[frameIndex] + let spec = { Variant = variant; Direction = direction; Frame = frame } + let source = characterSourceRectangle spec + let offsetX = source.X + let robe, sash = + match variant with + | IndigoRobe -> rgba 46 57 108 255, rgba 104 145 183 255 + | OchreVest -> rgba 141 91 45 255, rgba 205 163 76 255 + | JadeSash -> rgba 46 113 91 255, rgba 121 185 139 255 + let skin = rgba 232 180 139 255 + let hair = rgba 42 36 37 255 + let shadow = rgba 23 31 34 120 + let legShift = if frame = FrameOne then 0 else 2 + fillRect pixels atlasWidth (offsetX + 7) 42 18 3 shadow + fillRect pixels atlasWidth (offsetX + 8 + legShift) 35 6 10 robe + fillRect pixels atlasWidth (offsetX + 18 - legShift) 35 6 10 robe + fillRect pixels atlasWidth (offsetX + 7) 22 18 16 robe + fillRect pixels atlasWidth (offsetX + 9) 32 14 4 sash + fillRect pixels atlasWidth (offsetX + 10) 12 12 10 skin + fillRect pixels atlasWidth (offsetX + 7) 8 18 4 hair + fillRect pixels atlasWidth (offsetX + 5) 7 22 3 sash + match direction with + | NorthFacing -> + fillRect pixels atlasWidth (offsetX + 9) 13 14 8 hair + | SouthFacing -> + fillRect pixels atlasWidth (offsetX + 12) 17 2 2 hair + fillRect pixels atlasWidth (offsetX + 18) 17 2 2 hair + | WestFacing -> + fillRect pixels atlasWidth (offsetX + 9) 16 3 5 hair + | EastFacing -> + fillRect pixels atlasWidth (offsetX + 20) 16 3 5 hair + + let texture = new Texture2D(device, atlasWidth, cellHeight, false, SurfaceFormat.Color) + texture.SetData pixels + texture + + let buildTextures (device: GraphicsDevice) : ArtTextures = + { TileAtlas = buildTileAtlas device + CharacterAtlas = buildCharacterAtlas device } + + let private tileSourceRectangle sprite : XnaRectangle = + let index = + match sprite with + | GrassSprite -> 0 + | WaterSprite -> 1 + | StonePathSprite -> 2 + | RoofSprite -> 3 + | WallSprite -> 4 + | BridgeSprite -> 5 + | BambooSprite -> 6 + | GardenSprite -> 7 + XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels) + + let private tilePosition x y : TilePosition = + { X = x + Y = y } + + let private isVisible (camera: XnaVector2) (viewportWidth: int) (viewportHeight: int) (position: TilePosition) = + let screenX = position.X * Sim.tilePixels - int camera.X + let screenY = position.Y * Sim.tilePixels - int camera.Y + screenX < viewportWidth + && screenX + Sim.tilePixels > 0 + && screenY < viewportHeight + && screenY + Sim.tilePixels > 0 + + let private drawTile (spriteBatch: SpriteBatch) (atlas: Texture2D) (camera: XnaVector2) (position: TilePosition) sprite tint = + let destination = + XnaRectangle( + position.X * Sim.tilePixels - int camera.X, + position.Y * Sim.tilePixels - int camera.Y, + Sim.tilePixels, + Sim.tilePixels) + spriteBatch.Draw(atlas, destination, tileSourceRectangle sprite, tint) + + let private drawWorldTile (spriteBatch: SpriteBatch) (textures: ArtTextures) (camera: XnaVector2) (viewportWidth: int) (viewportHeight: int) position sprite tint = + if isVisible camera viewportWidth viewportHeight position then + drawTile spriteBatch textures.TileAtlas camera position sprite tint + + let private offsetTile (position: TilePosition) dx dy : TilePosition = + tilePosition (position.X + dx) (position.Y + dy) + + let drawWorld + (spriteBatch: SpriteBatch) + (textures: ArtTextures) + (camera: XnaVector2) + (viewportWidth: int) + (viewportHeight: int) + (plan: JiangnanRenderPlan) + (tint: XnaColor) = + let draw position sprite = + drawWorldTile spriteBatch textures camera viewportWidth viewportHeight position sprite tint + + let tx0 = max 0 (int camera.X / Sim.tilePixels) + let ty0 = max 0 (int camera.Y / Sim.tilePixels) + let tx1 = min (Sim.mapWidthTiles - 1) ((int camera.X + viewportWidth) / Sim.tilePixels + 1) + let ty1 = min (Sim.mapHeightTiles - 1) ((int camera.Y + viewportHeight) / Sim.tilePixels + 1) + for y in ty0 .. ty1 do + for x in tx0 .. tx1 do + draw (tilePosition x y) GrassSprite + + for localPosition, kind in plan.Grounds do + let position = worldTile plan localPosition + let sprite = + match kind with + | Water -> WaterSprite + | StonePath -> StonePathSprite + draw position sprite + + let drawHouse localPosition = + let position = worldTile plan localPosition + for dx in -1 .. 1 do + draw (offsetTile position dx 0) RoofSprite + draw (offsetTile position dx 1) WallSprite + draw (offsetTile position 0 1) BridgeSprite + + let drawBridge localPosition = + let position = worldTile plan localPosition + draw position BridgeSprite + draw (offsetTile position 1 0) BridgeSprite + + let drawBamboo localPosition = + let position = worldTile plan localPosition + draw position BambooSprite + draw (offsetTile position 0 1) BambooSprite + draw (offsetTile position 1 0) BambooSprite + + let drawGarden localPosition = + let position = worldTile plan localPosition + draw position GardenSprite + draw (offsetTile position 1 0) GardenSprite + + for prop in plan.Props do + match prop.Kind with + | House -> drawHouse prop.Position + | Bridge -> drawBridge prop.Position + | BambooGrove -> drawBamboo prop.Position + | VegetablePatch -> drawGarden prop.Position + + let drawCharacter + (spriteBatch: SpriteBatch) + (textures: ArtTextures) + (camera: XnaVector2) + (position: Vec2) + (spec: NpcSpriteSpec) + (tint: XnaColor) = + let destination = + XnaRectangle( + int position.X - int camera.X - 16, + int position.Y - int camera.Y - 48, + 32, + 48) + spriteBatch.Draw(textures.CharacterAtlas, destination, characterSourceRectangle spec, tint) + + let drawInterior (spriteBatch: SpriteBatch) (pixel: Texture2D) (viewportWidth: int) (viewportHeight: int) (plan: InteriorPlan) = + let roomX = 40 + let roomY = 40 + let roomWidth = max 320 (viewportWidth - 80) + let roomHeight = max 240 (viewportHeight - 80) + spriteBatch.Draw(pixel, XnaRectangle(0, 0, viewportWidth, viewportHeight), rgba 18 25 42 255) + spriteBatch.Draw(pixel, XnaRectangle(roomX, roomY, roomWidth, roomHeight), rgba 188 163 126 255) + spriteBatch.Draw(pixel, XnaRectangle(roomX, roomY, roomWidth, 18), rgba 65 57 59 255) + spriteBatch.Draw(pixel, XnaRectangle(roomX, roomY + roomHeight - 18, roomWidth, 18), rgba 99 70 50 255) + + for element in plan.Elements do + let x = roomX + element.Position.X * Sim.tilePixels + let y = roomY + element.Position.Y * Sim.tilePixels + match element.Kind with + | Window -> + spriteBatch.Draw(pixel, XnaRectangle(x, y, 48, 30), rgba 86 143 170 255) + spriteBatch.Draw(pixel, XnaRectangle(x + 22, y, 4, 30), rgba 225 216 195 255) + | Table -> + spriteBatch.Draw(pixel, XnaRectangle(x, y, 64, 32), rgba 119 76 47 255) + spriteBatch.Draw(pixel, XnaRectangle(x + 8, y + 28, 8, 20), rgba 83 52 39 255) + spriteBatch.Draw(pixel, XnaRectangle(x + 48, y + 28, 8, 20), rgba 83 52 39 255) + | Bed -> + spriteBatch.Draw(pixel, XnaRectangle(x, y, 48, 80), rgba 114 62 67 255) + spriteBatch.Draw(pixel, XnaRectangle(x + 4, y + 4, 40, 24), rgba 224 211 185 255) + | Screen -> + spriteBatch.Draw(pixel, XnaRectangle(x, y, 16, 80), rgba 61 45 49 255) + spriteBatch.Draw(pixel, XnaRectangle(x + 12, y + 4, 8, 72), rgba 142 102 72 255) diff --git a/src/LivingVillage.Desktop/VillagePresentation.fs b/src/LivingVillage.Desktop/VillagePresentation.fs index 95dc178..eee7fac 100644 --- a/src/LivingVillage.Desktop/VillagePresentation.fs +++ b/src/LivingVillage.Desktop/VillagePresentation.fs @@ -1,5 +1,8 @@ namespace LivingVillage.Desktop +open System +open LivingVillage.Kernel.Sim + module VillagePresentation = (** @@ -18,6 +21,11 @@ module VillagePresentation = Y: int } [<Struct>] + type PixelPosition = + { X: float32 + Y: float32 } + + [<Struct>] type SimulationTick = | SimulationTick of int64 @@ -74,6 +82,11 @@ module VillagePresentation = | Outside | Inside of HomeId + type HomeInputAction = + | EnterHomeAction of HomeId + | ExitHomeAction of HomeId + | DialogueAction + type HomeTransitionError = | NotNearHomeDoor | AlreadyInsideHome of HomeId @@ -120,7 +133,18 @@ module VillagePresentation = let scene : PrototypeScene = createScene () - let private manhattanDistance first second : int = + let sceneOrigin : TilePosition = + tile 25 18 + + let sceneTileFromPixel (pixel: PixelPosition) : TilePosition = + { X = int (Math.Floor(float pixel.X / float tilePixels)) - sceneOrigin.X + Y = int (Math.Floor(float pixel.Y / float tilePixels)) - sceneOrigin.Y } + + let worldTileForSceneTile (sceneTile: TilePosition) : TilePosition = + { X = sceneOrigin.X + sceneTile.X + Y = sceneOrigin.Y + sceneTile.Y } + + let private manhattanDistance (first: TilePosition) (second: TilePosition) : int = abs (first.X - second.X) + abs (first.Y - second.Y) let isNearHomeDoor (position: TilePosition) (home: Home) : bool = @@ -143,6 +167,25 @@ module VillagePresentation = else Error(HomeNotFound homeId) + let homeInputAction (pixel: PixelPosition) (mode: HomeMode) : HomeInputAction = + let scenePosition = sceneTileFromPixel pixel + + match mode with + | Outside -> + match enterHome scenePosition Outside with + | Ok(Inside homeId) -> EnterHomeAction homeId + | _ -> DialogueAction + | Inside homeId -> + match scene.Homes |> List.tryFind (fun home -> home.Id = homeId) with + | Some home when isNearHomeDoor scenePosition home -> + match exitHome mode with + | Ok Outside -> ExitHomeAction homeId + | _ -> DialogueAction + | _ -> DialogueAction + + let resetHomeMode () : HomeMode = + Outside + let directionForInput (input: DirectionInput) : Direction option = match input with | UpInput -> Some North |
