From b237a11908442a227361fae24dbdd0dbb287fac9 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Fri, 18 Sep 2026 22:48:42 +0800 Subject: feat: M0 骨架 — F# 解决方案 + MonoGame DesktopGL 游戏循环 + 32x32 瓦片地图 + 化身 WASD + 镜头跟随 + FPS 计数 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LivingVillage.sln | 34 +++++ src/LivingVillage.Desktop/Game.fs | 149 +++++++++++++++++++++ .../LivingVillage.Desktop.fsproj | 21 +++ src/LivingVillage.Desktop/Program.fs | 9 ++ .../LivingVillage.Kernel.fsproj | 11 ++ src/LivingVillage.Kernel/Sim.fs | 46 +++++++ 6 files changed, 270 insertions(+) create mode 100644 LivingVillage.sln create mode 100644 src/LivingVillage.Desktop/Game.fs create mode 100644 src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj create mode 100644 src/LivingVillage.Desktop/Program.fs create mode 100644 src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj create mode 100644 src/LivingVillage.Kernel/Sim.fs diff --git a/LivingVillage.sln b/LivingVillage.sln new file mode 100644 index 0000000..4e8f210 --- /dev/null +++ b/LivingVillage.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{DFF4B1C6-CF0A-45CB-912B-5BFD446A76FF}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "LivingVillage.Kernel", "src\LivingVillage.Kernel\LivingVillage.Kernel.fsproj", "{504BBDD4-106D-4423-A3BE-9A82848D1656}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "LivingVillage.Desktop", "src\LivingVillage.Desktop\LivingVillage.Desktop.fsproj", "{CC15DA38-E36A-45A2-A031-5392C8A6BF0D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {504BBDD4-106D-4423-A3BE-9A82848D1656}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {504BBDD4-106D-4423-A3BE-9A82848D1656}.Debug|Any CPU.Build.0 = Debug|Any CPU + {504BBDD4-106D-4423-A3BE-9A82848D1656}.Release|Any CPU.ActiveCfg = Release|Any CPU + {504BBDD4-106D-4423-A3BE-9A82848D1656}.Release|Any CPU.Build.0 = Release|Any CPU + {CC15DA38-E36A-45A2-A031-5392C8A6BF0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC15DA38-E36A-45A2-A031-5392C8A6BF0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC15DA38-E36A-45A2-A031-5392C8A6BF0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC15DA38-E36A-45A2-A031-5392C8A6BF0D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {504BBDD4-106D-4423-A3BE-9A82848D1656} = {DFF4B1C6-CF0A-45CB-912B-5BFD446A76FF} + {CC15DA38-E36A-45A2-A031-5392C8A6BF0D} = {DFF4B1C6-CF0A-45CB-912B-5BFD446A76FF} + EndGlobalSection +EndGlobal diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs new file mode 100644 index 0000000..960a8c6 --- /dev/null +++ b/src/LivingVillage.Desktop/Game.fs @@ -0,0 +1,149 @@ +namespace LivingVillage.Desktop + +open System +open Microsoft.Xna.Framework +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 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 (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 + +type LivingVillageGame() as this = + inherit Game() + + let graphics = new GraphicsDeviceManager(this) + let mutable spriteBatch = Unchecked.defaultof + let mutable atlas = Unchecked.defaultof + let mutable pixel = Unchecked.defaultof + let mutable map = MapGen.generate Sim.mapWidthTiles Sim.mapHeightTiles + let mutable world = Sim.initialWorld () + let mutable camera = Vector2.Zero + let mutable fpsFrames = 0 + let mutable fpsSeconds = 0.0 + + do + graphics.PreferredBackBufferWidth <- 1280 + graphics.PreferredBackBufferHeight <- 720 + graphics.SynchronizeWithVerticalRetrace <- true + this.IsFixedTimeStep <- true + this.TargetElapsedTime <- TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 60L) + this.Window.Title <- "Living Village M0" + + member private this.CenterCamera() = + let viewport = this.GraphicsDevice.Viewport + let vw = float32 viewport.Width + let vh = float32 viewport.Height + let worldW = float32 (Sim.mapWidthTiles * Sim.tilePixels) + let worldH = float32 (Sim.mapHeightTiles * Sim.tilePixels) + let half = float32 Sim.tilePixels / 2.0f + let x = MathHelper.Clamp(world.Avatar.Pos.X + half - vw / 2.0f, 0.0f, max 0.0f (worldW - vw)) + let y = MathHelper.Clamp(world.Avatar.Pos.Y + half - vh / 2.0f, 0.0f, max 0.0f (worldH - vh)) + camera <- Vector2(x, y) + + override _.LoadContent() = + spriteBatch <- new SpriteBatch(this.GraphicsDevice) + atlas <- Atlas.build this.GraphicsDevice + pixel <- new Texture2D(this.GraphicsDevice, 1, 1) + pixel.SetData([| Color.White |]) + + override this.Update(gameTime: GameTime) = + let kb = Keyboard.GetState() + if kb.IsKeyDown(Keys.Escape) then this.Exit() + let input = + { MoveX = + (if kb.IsKeyDown(Keys.D) then 1.0f + elif kb.IsKeyDown(Keys.A) then -1.0f + else 0.0f) + MoveY = + (if kb.IsKeyDown(Keys.S) then 1.0f + elif kb.IsKeyDown(Keys.W) then -1.0f + else 0.0f) } + let ts = + { Dt = float32 gameTime.ElapsedGameTime.TotalSeconds + Input = input } + world <- Sim.step ts world + this.CenterCamera() + fpsFrames <- fpsFrames + 1 + fpsSeconds <- fpsSeconds + gameTime.ElapsedGameTime.TotalSeconds + if fpsSeconds >= 1.0 then + let fps = float fpsFrames / fpsSeconds + fpsFrames <- 0 + fpsSeconds <- 0.0 + this.Window.Title <- $"Living Village M0 | fps {fps:F1} | tick {world.Tick}" + printfn $"fps={fps:F1} tick={world.Tick} pos=({world.Avatar.Pos.X:F0},{world.Avatar.Pos.Y:F0})" + + override this.Draw(gameTime: GameTime) = + this.GraphicsDevice.Clear(Color.CornflowerBlue) + 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) + 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, Color.White) + let avatarDst = + Rectangle(int world.Avatar.Pos.X - camX, int world.Avatar.Pos.Y - camY, Sim.tilePixels, Sim.tilePixels) + spriteBatch.Draw(pixel, avatarDst, Color.IndianRed) + spriteBatch.End() diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj new file mode 100644 index 0000000..9af59ac --- /dev/null +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + + + + + + + + + + + + + + + + diff --git a/src/LivingVillage.Desktop/Program.fs b/src/LivingVillage.Desktop/Program.fs new file mode 100644 index 0000000..0e2adcf --- /dev/null +++ b/src/LivingVillage.Desktop/Program.fs @@ -0,0 +1,9 @@ +namespace LivingVillage.Desktop + +module Program = + + [] + let main _ = + use game = new LivingVillageGame() + game.Run() + 0 diff --git a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj new file mode 100644 index 0000000..a8ed312 --- /dev/null +++ b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj @@ -0,0 +1,11 @@ + + + + net8.0 + + + + + + + diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs new file mode 100644 index 0000000..d1b2e57 --- /dev/null +++ b/src/LivingVillage.Kernel/Sim.fs @@ -0,0 +1,46 @@ +namespace LivingVillage.Kernel + +module Sim = + + type Vec2 = + { X: float32 + Y: float32 } + + type Avatar = + { Pos: Vec2 } + + type Input = + { MoveX: float32 + MoveY: float32 } + + type TimeStep = + { Dt: float32 + Input: Input } + + type World = + { Tick: int64 + Avatar: Avatar } + + let mapWidthTiles = 64 + let mapHeightTiles = 48 + let tilePixels = 32 + let avatarSpeed = 160.0f + + let initialWorld () : World = + let centerX = float32 (mapWidthTiles * tilePixels / 2 - tilePixels / 2) + let centerY = float32 (mapHeightTiles * tilePixels / 2 - tilePixels / 2) + { Tick = 0L + Avatar = { Pos = { X = centerX; Y = centerY } } } + + let clamp v lo hi = if v < lo then lo elif v > hi then hi else v + + let step (ts: TimeStep) (world: World) : World = + let maxX = float32 (mapWidthTiles * tilePixels - tilePixels) + let maxY = float32 (mapHeightTiles * tilePixels - tilePixels) + let dx = ts.Input.MoveX * avatarSpeed * ts.Dt + let dy = ts.Input.MoveY * avatarSpeed * ts.Dt + { Tick = world.Tick + 1L + Avatar = + { Pos = + { X = clamp (world.Avatar.Pos.X + dx) 0.0f maxX + Y = clamp (world.Avatar.Pos.Y + dy) 0.0f maxY } } } -- cgit v1.2.3