Fix gas poisoning, explosion hit detection and render order

- Move gas poisoning from Gas.move() to Gas.collisions() so all rats are
  registered in the collision system before the poison query runs.
- Shrink gas and explosion bboxes so rats must be well inside the tile to
  be poisoned/killed.
- Use AABB overlap instead of partial_move threshold for gas poisoning.
- Draw mobile units first, then top-layer effects (gas, mines, bombs,
  explosions) so rats appear under the gas.
- Add draw_on_top hint to Unit base class and top-layer units.
This commit is contained in:
2026-06-16 19:19:14 +02:00
parent 3e8cd97fda
commit 2f8e3e8b28
7 changed files with 88 additions and 33 deletions
+13
View File
@@ -9,6 +9,19 @@ This structure is designed to be easily portable to Nim.
from typing import Dict, List, Tuple, Set from typing import Dict, List, Tuple, Set
from dataclasses import dataclass from dataclasses import dataclass
def shrink_bbox(bbox: Tuple[float, float, float, float], ratio: float) -> Tuple[float, float, float, float]:
"""Return a smaller bbox centered on the original one.
ratio is the fraction of width/height removed from each side.
E.g. ratio=0.25 keeps the central 50% of the original area.
"""
x1, y1, x2, y2 = bbox
dx = (x2 - x1) * ratio / 2
dy = (y2 - y1) * ratio / 2
return (x1 + dx, y1 + dy, x2 - dx, y2 - dy)
@dataclass @dataclass
class CollisionLayer: class CollisionLayer:
"""Define which types of units can collide with each other.""" """Define which types of units can collide with each other."""
+15 -1
View File
@@ -516,7 +516,21 @@ class MiceMaze:
# Pass 2: RESOLVE and DRAW # Pass 2: RESOLVE and DRAW
for unit in sorted_units: for unit in sorted_units:
unit.collisions() unit.collisions()
unit.draw()
# Draw mobile units first so ground effects appear on top
for unit in sorted_units:
if not getattr(unit, "draw_on_top", False):
unit.draw()
# Draw top-layer effects on top of mobile units
for unit in sorted_units:
if getattr(unit, "draw_on_top", False) and not getattr(unit, "draw_last", False):
unit.draw()
# Draw foreground/last-layer units (points) on top of everything
for unit in sorted_units:
if getattr(unit, "draw_last", False):
unit.draw()
self.graphics.draw_cave_foreground() self.graphics.draw_cave_foreground()
self.render_engine.draw_pointer(self.pointer[0] * self.cell_size, self.pointer[1] * self.cell_size) self.render_engine.draw_pointer(self.pointer[0] * self.cell_size, self.pointer[1] * self.cell_size)
+25 -9
View File
@@ -1,16 +1,19 @@
from .unit import Unit, UnitType from .unit import Unit, UnitType
from . import rat from . import rat
from .points import Point from .points import Point
from engine.collision_system import CollisionLayer from engine.collision_system import CollisionLayer, shrink_bbox
import uuid import uuid
import random import random
# Costanti # Costanti
AGE_THRESHOLD = 200 AGE_THRESHOLD = 200
NUCLEAR_TIMER = 50 # 1 second at ~50 FPS NUCLEAR_TIMER = 50 # 1 second at ~50 FPS
EXPLOSION_BBOX_SHRINK = 0.25 # central 50% of the tile is lethal instantly
class Bomb(Unit): class Bomb(Unit):
draw_on_top = True
def __init__(self, game, position=(0,0), id=None, unit_type=UnitType.BOMB_TIMER): def __init__(self, game, position=(0,0), id=None, unit_type=UnitType.BOMB_TIMER):
super().__init__(game, position, id, collision_layer=CollisionLayer.BOMB, unit_type=unit_type) super().__init__(game, position, id, collision_layer=CollisionLayer.BOMB, unit_type=unit_type)
# Specific attributes for bombs # Specific attributes for bombs
@@ -104,13 +107,24 @@ class Timer(Bomb):
layer_filter=CollisionLayer.RAT layer_filter=CollisionLayer.RAT
) )
cs = self.game.cell_size
for victim_id in victim_ids: for victim_id in victim_ids:
victim = self.game.unit_manager.get_unit_by_id(victim_id) victim = self.game.unit_manager.get_unit_by_id(victim_id)
if victim and victim.id in self.game.units: if victim and victim.id in self.game.units:
if victim.position in explosion_positions or victim.position_before in explosion_positions: # Require the rat to be well inside an explosion tile
victim.die(score=score) for pos in explosion_positions:
if score < 160: if victim.position == pos or victim.position_before == pos:
score *= 2 exp_bbox = shrink_bbox(
(pos[0] * cs, pos[1] * cs, (pos[0] + 1) * cs, (pos[1] + 1) * cs),
EXPLOSION_BBOX_SHRINK,
)
vb = victim.bbox
if (vb[0] < exp_bbox[2] and vb[2] > exp_bbox[0] and
vb[1] < exp_bbox[3] and vb[3] > exp_bbox[1]):
victim.die(score=score)
if score < 160:
score *= 2
break
def draw(self): def draw(self):
"""Don't draw a bomb that has already exploded.""" """Don't draw a bomb that has already exploded."""
@@ -130,10 +144,12 @@ class Explosion(Bomb):
self.age += self.speed self.age += self.speed
if self.age >= AGE_THRESHOLD: if self.age >= AGE_THRESHOLD:
self.die() self.die()
# Set bbox so lingering explosions can kill rats via collision system # Set bbox so lingering explosions can kill rats via collision system.
x = self.position[0] * self.game.cell_size # Only the central area is lethal.
y = self.position[1] * self.game.cell_size cs = self.game.cell_size
self.bbox = (float(x), float(y), float(x + self.game.cell_size), float(y + self.game.cell_size)) cell_bbox = (self.position[0] * cs, self.position[1] * cs,
(self.position[0] + 1) * cs, (self.position[1] + 1) * cs)
self.bbox = shrink_bbox(cell_bbox, EXPLOSION_BBOX_SHRINK)
def collisions(self): def collisions(self):
"""Lingering explosion kills any rat that touches it.""" """Lingering explosion kills any rat that touches it."""
+19 -22
View File
@@ -1,11 +1,14 @@
from .unit import Unit, UnitType from .unit import Unit, UnitType
from engine.collision_system import CollisionLayer from engine.collision_system import CollisionLayer, shrink_bbox
import random import random
# Costanti # Costanti
AGE_THRESHOLD = 200 AGE_THRESHOLD = 200
GAS_BBOX_SHRINK = 0.3 # central 40% of the tile is poisonous
class Gas(Unit): class Gas(Unit):
draw_on_top = True
def __init__(self, game, position=(0,0), id=None, parent_id=None): def __init__(self, game, position=(0,0), id=None, parent_id=None):
super().__init__(game, position, id, collision_layer=CollisionLayer.GAS, unit_type=UnitType.GAS) super().__init__(game, position, id, collision_layer=CollisionLayer.GAS, unit_type=UnitType.GAS)
self.parent_id = parent_id self.parent_id = parent_id
@@ -25,27 +28,14 @@ class Gas(Unit):
return return
self.age += 1 self.age += 1
# Use optimized collision system to find rats in gas cloud # Register a smaller bbox so rats must be well inside the tile to be gassed
victim_ids = self.game.collision_system.get_units_in_cell( cell_bbox = (
self.position, use_before=False self.position[0] * self.game.cell_size,
self.position[1] * self.game.cell_size,
(self.position[0] + 1) * self.game.cell_size,
(self.position[1] + 1) * self.game.cell_size,
) )
self.bbox = shrink_bbox(cell_bbox, GAS_BBOX_SHRINK)
for victim_id in victim_ids:
victim = self.game.unit_manager.get_unit_by_id(victim_id)
if victim and victim.type in [UnitType.RAT_MALE, UnitType.RAT_FEMALE]:
if victim.partial_move > 0.5:
victim.gassed += 1
# Check position_before as well
victim_ids_before = self.game.collision_system.get_units_in_cell(
self.position, use_before=True
)
for victim_id in victim_ids_before:
victim = self.game.unit_manager.get_unit_by_id(victim_id)
if victim and victim.type in [UnitType.RAT_MALE, UnitType.RAT_FEMALE]:
if victim.partial_move < 0.5:
victim.gassed += 1
if self.age % self.speed: if self.age % self.speed:
return return
@@ -61,8 +51,15 @@ class Gas(Unit):
if not any(unit.type == UnitType.GAS for unit in self.game.units.values() if unit.position == (new_x, new_y)): if not any(unit.type == UnitType.GAS for unit in self.game.units.values() if unit.position == (new_x, new_y)):
print(f"Spreading gas from {self.position} to ({new_x}, {new_y})") print(f"Spreading gas from {self.position} to ({new_x}, {new_y})")
self.game.unit_manager.spawn_unit(Gas, (new_x, new_y), parent_id=self.parent_id if self.parent_id else self.id) self.game.unit_manager.spawn_unit(Gas, (new_x, new_y), parent_id=self.parent_id if self.parent_id else self.id)
def collisions(self): def collisions(self):
pass """Poison rats that overlap the shrunk gas tile in Pass 2."""
for _, victim_id in self.game.collision_system.get_collisions_for_unit(
self.id, CollisionLayer.GAS, tolerance=0
):
victim = self.game.unit_manager.get_unit_by_id(victim_id)
if victim and victim.type in [UnitType.RAT_MALE, UnitType.RAT_FEMALE] and victim.id in self.game.units:
victim.gassed += 1
def die(self, unit=None, score=None): def die(self, unit=None, score=None):
if not unit: if not unit:
+2
View File
@@ -3,6 +3,8 @@ from .bomb import Explosion
from engine.collision_system import CollisionLayer from engine.collision_system import CollisionLayer
class Mine(Unit): class Mine(Unit):
draw_on_top = True
def __init__(self, game, position=(0,0), id=None): def __init__(self, game, position=(0,0), id=None):
super().__init__(game, position, id, collision_layer=CollisionLayer.MINE, unit_type=UnitType.MINE) super().__init__(game, position, id, collision_layer=CollisionLayer.MINE, unit_type=UnitType.MINE)
self.speed = 1.0 # Mine doesn't move but needs speed for consistency self.speed = 1.0 # Mine doesn't move but needs speed for consistency
+3 -1
View File
@@ -12,7 +12,9 @@ class Point(Unit):
Represents a collectible point in the game. Represents a collectible point in the game.
Appears when a rat dies and can be collected by the player. Appears when a rat dies and can be collected by the player.
""" """
draw_on_top = True
draw_last = True
def __init__(self, game, position=(0,0), id=None, value=10): def __init__(self, game, position=(0,0), id=None, value=10):
super().__init__(game, position, id, collision_layer=CollisionLayer.POINT, unit_type=UnitType.POINT) super().__init__(game, position, id, collision_layer=CollisionLayer.POINT, unit_type=UnitType.POINT)
self.value = value self.value = value
+11
View File
@@ -40,6 +40,9 @@ class Unit(ABC):
Collision layer for the optimized collision system. Collision layer for the optimized collision system.
type : UnitType type : UnitType
The specific type of the unit. The specific type of the unit.
draw_last : bool
Render order hint: when True, the unit is drawn after all other
top-layer units so it always appears in the foreground (e.g. points).
Methods Methods
------- -------
@@ -52,6 +55,14 @@ class Unit(ABC):
die() die()
Remove unit from game and handle cleanup. Remove unit from game and handle cleanup.
""" """
# Render order hint: units with draw_on_top=True are drawn after mobile
# units so they appear layered on top (e.g. gas, mines, explosions).
draw_on_top = False
# Render order hint: units with draw_last=True are drawn after all other
# top-layer units so they always appear in the foreground.
draw_last = False
def __init__(self, game, position=(0, 0), id=None, collision_layer=0, unit_type=None): def __init__(self, game, position=(0, 0), id=None, collision_layer=0, unit_type=None):
"""Initialize a unit with game reference and position.""" """Initialize a unit with game reference and position."""
self.id = id if id else uuid.uuid4() self.id = id if id else uuid.uuid4()