diff options
Diffstat (limited to 'src/LivingVillage.Desktop')
| -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 |
9 files changed, 1064 insertions, 181 deletions
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 |
