From b5ffb35fc0824a297ff88297390310acb6c45335 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 21:53:53 +0800 Subject: fix(desktop): 灯笼光晕按自身 alpha 预乘并改径向层叠,消除夜间纯白方块 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 VillageArt.premultiply:MonoGame SpriteBatch 默认预乘混合,直传 Color(r,g,b,a) 会直接加 rgb 导致多层暖色饱和成 75x75 纯白块 - lanternGlowLayers 用同心圆 pixel run 做径向衰减,逐层预乘,只在 RiverLantern 实体处绘制 - 水面相位改 waterFrameTickAt=(tick/32+x+2y)%3,同一帧相邻砖相位不同 - 炊烟改为 drawWorld 末尾独立一遍,位置 offsetTile 0 -3,画在屋瓦/竹子之上 - 角色脚底对齐 characterFeetOffset=16 并加预乘椭圆接触阴影 - 补全 Interaction.itemName 的 ItemKind 匹配(否则非 Food 交易会崩溃且告警) --- src/LivingVillage.Desktop/VillageArt.fs | 115 +++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 18 deletions(-) (limited to 'src/LivingVillage.Desktop/VillageArt.fs') diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index abda888..4845408 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -304,6 +304,12 @@ module VillageArt = let smokeFrameTick (tick: int64) : int = int (positiveModulo64 3L (tick / 40L)) + /// Per-tile water ripple phase. The global 32-tick cycle is offset by world tile + /// coordinates, so neighbouring river tiles show different phases in the same frame + /// instead of the whole surface switching in lockstep. + let waterFrameTickAt (tick: int64) (tileX: int) (tileY: int) : int = + int (positiveModulo64 3L (tick / 32L + int64 (tileX + 2 * tileY))) + let directionFromVector (vector: Vec2) : FacingDirection = if abs vector.X < 0.001f && abs vector.Y < 0.001f then SouthFacing @@ -360,6 +366,76 @@ module VillageArt = let private rgba (r: int) (g: int) (b: int) (a: int) : XnaColor = XnaColor(r, g, b, a) + /// MonoGame's default SpriteBatch blend (BlendState.AlphaBlend) treats the sprite + /// format as premultiplied alpha: the source colour blend is `One`, so a straight + /// Color(r,g,b,a) adds its raw rgb to the framebuffer and layered glows saturate to + /// an opaque white block. Scale every channel by its own alpha before drawing. + let premultiply (r: int) (g: int) (b: int) (a: int) : Microsoft.Xna.Framework.Color = + let clampChannel (channel: int) = max 0 (min 255 channel) + let alpha = clampChannel a + let scale (channel: int) = + Math.Round(float (clampChannel channel) * float alpha / 255.0, MidpointRounding.AwayFromZero) + |> int + Microsoft.Xna.Framework.Color(scale r, scale g, scale b, alpha) + + /// Horizontal pixel runs approximating a filled ellipse; used for soft contact shadows. + let ellipseRuns (centerX: int) (centerY: int) (radiusX: int) (radiusY: int) : (int * int * int) list = + if radiusX <= 0 || radiusY <= 0 then + [] + else + [ for dy in -radiusY .. radiusY do + let t = float dy / float radiusY + let half = int (Math.Round(float radiusX * Math.Sqrt(max 0.0 (1.0 - t * t)))) + if half > 0 then + yield (centerX - half, centerY + dy, half * 2 + 1) ] + + /// Soft radial lantern glow drawn as concentric filled circles. Every ring carries a + /// low, premultiplied warm alpha so the rings sum to `peakAlpha` at the core and thin + /// out toward the rim; because each ring's rgb is premultiplied, the core can never + /// saturate to an opaque white block the way straight rgba layers did. + let lanternGlowLayers + (centerX: int) + (centerY: int) + (radius: int) + (rings: int) + (red: int) + (green: int) + (blue: int) + (peakAlpha: int) : (Microsoft.Xna.Framework.Rectangle * Microsoft.Xna.Framework.Color) list = + if radius <= 0 || rings <= 0 || peakAlpha <= 0 then + [] + else + let perRing = max 1 (peakAlpha / rings) + let glowColor = premultiply red green blue perRing + [ for ring in 0 .. rings - 1 do + let ringRadius = max 1 (radius * (rings - ring) / rings) + for (x, y, width) in ellipseRuns centerX centerY ringRadius ringRadius do + yield (Microsoft.Xna.Framework.Rectangle(x, y, width, 1), glowColor) ] + + /// Characters spawn at tile centres; push the sprite down half a tile so its feet rest + /// on the tile's ground line instead of floating at the centre. + let characterFeetOffset = Sim.tilePixels / 2 + + /// Premultiplied translucent shadow colour used beneath characters. + let shadowColor : Microsoft.Xna.Framework.Color = premultiply 12 16 22 96 + + let characterDestination (position: Vec2) (camera: Microsoft.Xna.Framework.Vector2) : Microsoft.Xna.Framework.Rectangle = + Microsoft.Xna.Framework.Rectangle( + int position.X - int camera.X - 16, + int position.Y - int camera.Y - 48 + characterFeetOffset, + 32, + 48) + + let drawGroundShadow + (spriteBatch: SpriteBatch) + (pixel: Texture2D) + (camera: Microsoft.Xna.Framework.Vector2) + (position: Vec2) = + let centerX = int position.X - int camera.X + let centerY = int position.Y - int camera.Y + characterFeetOffset - 2 + for (x, y, width) in ellipseRuns centerX centerY 13 5 do + spriteBatch.Draw(pixel, Microsoft.Xna.Framework.Rectangle(x, y, width, 1), shadowColor) + let avatarSpriteSpec (direction: FacingDirection) (isMoving: bool) (tick: int64) : NpcSpriteSpec = { Variant = StrawHat Direction = direction @@ -481,9 +557,11 @@ module VillageArt = (tint: XnaColor) = let draw position sprite = drawWorldTile spriteBatch textures camera viewportWidth viewportHeight position sprite tint - let rippleFrame = waterFrameTick tick - let waterSprite = - match rippleFrame with + + // Each water tile picks its phase from its own world coordinates, so a single + // frame shows the three ripple phases across the river instead of one global frame. + let waterSpriteAt (position: TilePosition) = + match waterFrameTickAt tick position.X position.Y with | 0 -> WaterSprite | 1 -> WaterSpriteB | _ -> WaterSpriteC @@ -492,7 +570,7 @@ module VillageArt = // onto the jiangnan atlas sprites with deterministic ripple continuity. let drawTerrainTile (position: TilePosition) (code: int) = match code with - | 1 -> draw position waterSprite + | 1 -> draw position (waterSpriteAt position) | 2 -> draw position StonePathSprite | 3 -> draw position StonePathSprite | _ -> draw position GrassSprite @@ -522,7 +600,7 @@ module VillageArt = for localPosition, kind in plan.Grounds do let position = worldTile plan localPosition match kind with - | Water -> draw position waterSprite + | Water -> draw position (waterSpriteAt position) | StonePath -> match textures.Cc0TileAtlas with | Some cc0 -> drawCc0Tile spriteBatch cc0 tint cc0PathRect (position.X * Sim.tilePixels - int camera.X) (position.Y * Sim.tilePixels - int camera.Y) @@ -537,13 +615,6 @@ module VillageArt = draw (offsetTile position -1 0) WallWindowSprite draw (offsetTile position 0 0) DoorSprite draw (offsetTile position 1 0) WallWoodSprite - // Chimney smoke drifting up from the roof, cycling through three puffs. - let smokeSprite = - match smokeFrameTick tick with - | 0 -> SmokeSpriteA - | 1 -> SmokeSpriteB - | _ -> SmokeSpriteC - draw (offsetTile position -2 -3) smokeSprite let drawBridge localPosition = let position = worldTile plan localPosition @@ -583,6 +654,19 @@ module VillageArt = | StoneBench -> drawSingleTile prop.Position BenchSprite | RiverLantern -> drawSingleTile prop.Position LanternSprite + // Chimney smoke is a late pass so it always reads above the roof, the bamboo and + // any other prop sharing its tile. The puff sits one row above the ridge centre + // (offset 0,-3) and cycles through three frames driven by smokeFrameTick. + let smokeSprite = + match smokeFrameTick tick with + | 0 -> SmokeSpriteA + | 1 -> SmokeSpriteB + | _ -> SmokeSpriteC + for prop in plan.Props do + match prop.Kind with + | House -> draw (offsetTile (worldTile plan prop.Position) 0 -3) smokeSprite + | _ -> () + let drawCharacter (spriteBatch: SpriteBatch) (textures: ArtTextures) @@ -590,12 +674,7 @@ module VillageArt = (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) + let destination = characterDestination position camera spriteBatch.Draw(textures.CharacterAtlas, destination, characterSourceRectangle spec, tint) type InteriorLayout = -- cgit v1.2.3