Fix monsters attacking through walls with ranged attacks - #957
Conversation
Add GameMapTerrain.HasLineOfSight (integer Bresenham over WalkMap with diagonal corner handling, endpoints excluded) plus a LocateableExtensions.HasLineOfSightTo helper, and gate all server-driven attack decisions on it: BasicMonsterIntelligence (target search prefers visible with nearest fallback, validity and attack gate require sight), SummonedMonsterIntelligence, and the ranged/area trap intelligences. Out-of-sight monsters now fall through to WalkToAsync and path around the wall instead of shooting through it. Adds GameMapTerrainTests coverage for clear, blocked, symmetric, endpoint-exempt and diagonal-corner sight lines.
|
I think we should also prevent selecting targets which are not in line of sight. But there are some things to consider - in some terrains are not only walls, but also deep holes, e.g. in dungeon or chaos castle. Monsters with range attacks can attack over these holes. |
The .att terrain bytes are a TerrainAttributeType bitmask, so treating every unwalkable tile as sight-blocking was wrong: holes (NoGround) and water can't be walked on, but ranged attacks fly across them (e.g. Dungeon pits, Chaos Castle ground). Only the Blocked bit stops projectiles. GameMapTerrain now keeps the raw attribute flags per tile and derives sight-blocking from the Blocked bit alone. Runtime terrain changes (mini-game ground collapse, Kanturu barrier, castle gates) go through the new ApplyTerrainAttribute method, which keeps raw flags, WalkMap, SafezoneMap and AIgrid consistent; gate close/open saves and restores the wall bit instead of a stale walkability snapshot. Non-safezone changes never alter safezone status, exactly as before. Extends GameMapTerrainTests with hole/water/stacked-bit and runtime transition cases; adjusts one CastleSiegeNpcTests setup to express its pre-blocked tile through the terrain API.
|
Now it checks for holes/water. Tested in Chaos Castle: |
sven-n
left a comment
There was a problem hiding this comment.
Thanks, this is a well-structured change — keeping the raw .att bitmask instead of collapsing it to walkable/unwalkable is the right foundation, the Bresenham implementation is allocation-free, and routing all runtime terrain writers through ApplyTerrainAttribute is a real improvement over the scattered WalkMap pokes (the byte loop counter fix at coordinate 255 is a nice catch along the way).
Four things from my side, one of them blocking:
1. Blood Castle regression (blocking) — recomputing walkability from the raw bits means a mini-game terrain change now only opens an area if the configured TerrainAttribute matches the bit actually present in the terrain file. The Blood Castle bridge is configured as NoGround, but the tiles are Blocked (4) on maps 11 and 13-17, and Blocked|NoGround (12) on map 52. Only Blood Castle 2 has plain NoGround there. So on 7 of 8 levels the bridge would never open. Details and suggested fixes in the inline comment on MiniGameContext.cs. Kanturu's barrier really is all 8, so that part of the change is correct.
2. HasLineOfSight is not symmetric for the diagonal corner case — the endpoint short-circuit runs before the closed-corner check, so a corner adjacent to the target doesn't block while the same corner adjacent to the viewer does. Inline on GameMapTerrain.cs.
3. Traps latch onto targets that lose sight — RandomAttackInRangeTrapIntelligence gates the attack on sight but never invalidates _currentTarget, and a trap can't reposition. Inline on that file.
4. Duplication / reference point in the target search — inline on SummonedMonsterIntelligence.cs, non-blocking.
One question on the sight mask
Blocked|NoGround (12) currently blocks sight, and the test HasLineOfSightBlockedHoleCombinationBlocks pins that down. That value is not rare in the shipped terrain data — it's the dominant out-of-bounds marker on the arena maps (Terrain41-47, Terrain51: ~58-63k tiles each), but it also appears inside playable maps: Terrain2.att (Dungeon) has 21592 tiles of pure 8 and 4660 of 12, and Terrain52.att has it on the Blood Castle bridge. Since your original concern was exactly the dungeon/Chaos Castle pits, it'd be worth confirming in-game that the pits you meant are plain 8 and not 12 — otherwise the fix would still block those shots on some maps.
On the "unknown high bits" limitation in the description: the data supports the permissive choice. Terrain19/Terrain54 use bit 32 (659 + 2707 + 32 tiles), and Terrain11/25/31/32/35-37 carry bits 64/128 on scattered tiles. These look like the client's TW_ACTION/TW_HEIGHT/TW_CAMERA_UP flags rather than walls, so not blocking on them is right — and where they're combined with bit 4 the mask still blocks, which is what you want. Might be worth naming those bits in the comment on SightBlockingAttributes.
Smaller notes
AttributeMapis exposed as a public mutablebyte[,], so a direct write silently desyncsWalkMap/AIgrid/SafezoneMap. Same shape as the existingWalkMap, so not a new sin — but now that all in-tree writers go throughApplyTerrainAttribute, consider whetherUpdateAiGridValuestill needs to be public.IsWalkableValuecould be written as(value & ~(byte)TerrainAttributeType.Safezone) == 0, which reads closer to the new bitmask model thanvalue == 0 || value == 1.- The two branches of
ApplyTerrainAttributeduplicate the set/clear expression; hoisting it above the safezone check would shorten the method. IsCurrentTargetValidnow drops the target the moment it leaves sight. That's fine for repathing, but it also means a player attacking a monster from behind a wall (player skills stay lenient, as you note) causes the monster to re-search every tick. Worth a quick look in-game that it doesn't produce visible target-flapping.
Generated by Claude Code
…ed target search - Fix Blood Castle regression: removing a terrain attribute now clears every movement-blocking bit (Blocked|NoGround|Water) instead of only the configured one, so the NoGround-toggled bridge also opens where the terrain carries Blocked (BC1, BC3-7) or Blocked|NoGround (BC8). Adds a regression test across all three raw states. - Fix diagonal line-of-sight asymmetry: the closed-corner check now runs before the endpoint return, so a corner pocket blocks identically from both sides; melee-adjacent tiles are explicitly always visible. Corner tests assert both directions. - Fix traps latching onto targets which lost sight: the sight check is part of the invalidation condition, so a walled-off target is dropped and the trap re-targets instead of going silent for everyone. - Extract the triplicated nearest/nearest-visible search into NpcTargetSelection.GetNearestPreferVisible, used by monster, summon and trap intelligences; document the summon's owner-centered radius versus summon-centered sight. - Small notes: UpdateAiGridValue is now private, IsWalkableValue uses the bitmask form, unknown high .att bits documented as non-blocking.
sven-n
left a comment
There was a problem hiding this comment.
Re-reviewed at 5a9019a. All four points from the last round are addressed, and CI is green.
First, a correction I owe you: the Blood Castle regression I reported in my last review was wrong. I looked up Terrain{mapNumber}.att, but TerrainUpdateHelper resolves map N to Terrain{N+1}.att — map numbers are 0-based, terrain files 1-based. Blood Castle 1 loads Terrain12.att (not Terrain11.att) and BC8 loads Terrain53.att; in both, the bridge tiles really are NoGround, exactly as configured. Your original per-bit removal would have worked. Details and what I'd suggest doing about the current version are in the inline comment on ApplyTerrainAttribute — short version: what you have now is also fine (it matches pre-PR master semantics), so nothing blocks the PR either way. Sorry for sending you down that path.
One thing still open: HasLineOfSight remains asymmetric, now because of Bresenham's tie-breaking rather than the corner rule. Minimal repro is a single wall at (10,11) with (10,10) ↔ (11,12) — true one way, false the other. Brute force over random layouts puts it at ~3.9% of pairs; a four-line endpoint normalization takes it to exactly 0. No production call site evaluates a pair both ways today, so nothing is visibly broken, but the tests do assert symmetry in their comments. Inline on the Bresenham setup.
What's fixed well:
- Diagonal corner pocket — checking the corner before the endpoint return is the right ordering, and the explicit melee-adjacency short-circuit at the top is a cleaner way to express "adjacent is always visible" than relying on return order. Good that the corner test now asserts both directions.
- Trap latching — adding the sight check to the invalidation condition is exactly right, and the comment explaining why a trap needs this when a monster doesn't (it can't walk around) is the kind of thing that stops someone "simplifying" it back out later.
NpcTargetSelection.GetNearestPreferVisible— nice extraction. PassinggetDistanceas a delegate rather than assuming distance-to-viewer is what lets the summon keep its owner-centered radius while checking sight from itself, and the comment there now makes that split explicit instead of looking like a bug.- Small notes —
UpdateAiGridValuegoing private, the bitmask form ofIsWalkableValue, and documenting the high.attbits all landed.
Nothing else from me. With the symmetry point resolved (or consciously deferred), this looks good to go from my side.
Generated by Claude Code
sven-n
left a comment
There was a problem hiding this comment.
Re-reviewed at d5fbefc. Both open points are resolved, CI is green, and I have nothing further. I've resolved all six threads.
Endpoint normalization — verified. I re-ran the brute force against the exact code as it now stands (including the melee short-circuit and the corner-before-endpoint ordering): 0 asymmetric pairs out of 7.6M ordered pairs over random wall layouts, against 3.9% before. All the existing sight cases still behave as their tests describe — clear lines, wall-between in both directions, endpoint exemptions, closed corner in both directions, single-wall corner visible, diagonal-adjacent visible. Placing the swap after the same-tile and melee checks is right: those are direction-independent anyway, so they don't need it, and the swap can't affect them. HasLineOfSightTieBreakingIsSymmetric pins the exact case down.
Revert of the remove-path — agreed, and I re-verified the whole caller surface on this branch rather than trusting the earlier analysis. There are exactly five ApplyTerrainAttribute call sites, and only three can pass false:
MiniGameContext— the only configured non-safezone removals in the codebase are Blood Castle's five areas, and every one matches the bits in the file actually loaded (Terrain12.att/Terrain53.att): bridgeNoGroundon8, the other fourBlockedon4. Chaos Castle only setsNoGroundand togglesSafezone, so it takes the other branches.KanturuContext— removesNoGroundfrom the barrier column, which is uniformly8inTerrain39.att(map38).CastleSiegeNpcController.ReleaseGateTerrain— this is the one the revert actually improves. With the per-bit clear the close/open cycle round-trips exactly: a gate tile at20(Blocked|Water) hasWasBlocked == trueand comes back as20, and a hypothetical16(water, no wall) closes to20and reopens to16instead of becoming walkable. Matchesmaster's "restore prior state" intent.
ApplyTerrainAttributeRemoveKeepsOtherBits is a good addition — it's the test that would have caught the round-trip problem, and it documents the contract the castle gates depend on.
The rest stands as reviewed: the corner-pocket ordering, the trap invalidation, and NpcTargetSelection.GetNearestPreferVisible all look right, and the comments explaining why each one is shaped that way should survive future refactors.
Good to merge from my side.
Generated by Claude Code
Problem
Monsters with ranged attacks (e.g. Hell Spider, Cursed Wizard)
could target and hit players standing behind walls. Target search
and the attack decision used pure Chebyshev distance with no terrain
check, and damage application only blocks on safezone.
Fix
GameMapTerrain.HasLineOfSight(from, to): integer Bresenham overthe raw
.attattribute flags; only tiles with theBlocked(wall)bit stop projectiles — holes (
NoGround) and water can be shotacross, as in the original game (Dungeon pits, Chaos Castle ground).
Endpoints are excluded so occupants never block themselves, and a
diagonal step squeezing between two corner-touching wall tiles is
treated as blocked. O(range) reads, zero allocations.
GameMapTerrain.AttributeMappreserves the rawTerrainAttributeTypebitmask per tile (the old code collapsed it to walkable/unwalkable and
lost the wall-vs-hole distinction), plus
BlocksSight(x, y)andApplyTerrainAttribute(x, y, attribute, set)for runtime changes.LocateableExtensions.HasLineOfSightTo: same-map guard + delegation,mirroring the existing
IsInRange/IsAtSafezoneextension pattern.Call sites keep cheap
IsInRange()as pre-filter, LOS second.BasicMonsterIntelligence(search prefers nearest visible withnearest fallback, validity + attack gate require sight — also covers
guards and summoned monsters' base behavior),
SummonedMonsterIntelligence(same visible-preferred pattern),RandomAttackInRangeTrapIntelligence,AttackAreaTargetInDirectionTrapIntelligence,AttackAreaWhenPressedTrapIntelligence.truthful:
MiniGameContext(ground collapse), Kanturu barrier, andcastle siege gates (close/open now saves and restores the wall bit
instead of a stale walkability snapshot). Non-safezone changes never
alter safezone status, exactly as before.
stays lenient).
Known limitations
.attfiles (e.g.32) default tonon-sight-blocking — the permissive direction, closest to pre-fix
behavior. If a map ever shows shooting through something solid, that
tile's flag is the first lead.
Tests
dotnet build src/GameLogic— 0 errors, no new warnings.MUnique.OpenMU.Tests: 892/892 passed (incl. 27 siege,133 mini-game, 18 terrain).
GameMapTerrainTestsnow covers: same-tile, clear lines,wall blocks symmetrically, endpoints never block, closed diagonal
corner blocked, single-wall corner visible, diagonally-adjacent
tiles visible, hole/water don't block (while asserting they're still
unwalkable),
Blocked|NoGroundblocks, diagonal-between-holespasses, and runtime wall/hole/safezone transitions incl. safezone
preservation.
CastleSiegeNpcTestssetup updated to express its pre-blockedtile through the terrain API (natural wall in gate area) instead of
a raw
WalkMappoke unreachable in production.Before patch
Screencast_20260911_193219.webm
After patch
Screencast_20260911_194949.webm-10mb.mp4