Skip to content

Commit 28f322a

Browse files
Cleptomaniaclaude
andcommitted
Add per-region collision channel/mask filtering to HitBox
Each hitbox region can now have a channel (what it is) and mask (what it collides with) bitmask. Collision only occurs when both sides agree: (A.channel & B.mask) != 0 AND (B.channel & A.mask) != 0. Adds channels() utility for converting 1-based channel numbers to bitmasks, RawHitBox v2 serialization format, and updates the multi-hitbox example to demonstrate channel-based filtering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0f6d7cc commit 28f322a

File tree

5 files changed

+515
-69
lines changed

5 files changed

+515
-69
lines changed

arcade/examples/sprite_multi_hitbox.py

Lines changed: 97 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""
2-
Sprite Multi-Region Hit Boxes
2+
Sprite Multi-Region Hit Boxes with Collision Channels
33
4-
Demonstrates sprites with multiple hit box regions. The player sprite
5-
has a "body" region and a "shield" region extending to one side.
6-
Coins that touch any region of the player are collected.
4+
Demonstrates sprites with multiple hit box regions and per-region
5+
collision channels. The player sprite has a "body" region on channel 1
6+
and a "shield" region on channel 2.
7+
8+
Gold coins are on channel 1 and can only be collected by the body.
9+
Blue gems are on channel 2 and can only be collected by the shield.
10+
Coins pass through the shield, and gems pass through the body.
711
812
Hit box outlines are drawn for visual debugging. Use A/D to rotate
913
the player and see both regions rotate together.
@@ -16,15 +20,16 @@
1620
import random
1721
import math
1822
import arcade
19-
from arcade.hitbox import HitBox
23+
from arcade.hitbox import HitBox, channels
2024

2125
WINDOW_WIDTH = 1280
2226
WINDOW_HEIGHT = 720
2327
WINDOW_TITLE = "Multi-Region Hit Box Example"
2428

2529
PLAYER_SPEED = 5.0
26-
COIN_SPEED = 2.0
27-
COIN_COUNT = 20
30+
ITEM_SPEED = 2.0
31+
COIN_COUNT = 15
32+
GEM_COUNT = 10
2833

2934

3035
class GameView(arcade.View):
@@ -34,7 +39,9 @@ def __init__(self):
3439
self.player_sprite = None
3540
self.player_list = None
3641
self.coin_list = None
37-
self.score = 0
42+
self.gem_list = None
43+
self.coin_score = 0
44+
self.gem_score = 0
3845
self.score_display = None
3946
self.background_color = arcade.csscolor.DARK_SLATE_GRAY
4047

@@ -46,9 +53,11 @@ def __init__(self):
4653
def setup(self):
4754
self.player_list = arcade.SpriteList()
4855
self.coin_list = arcade.SpriteList()
49-
self.score = 0
56+
self.gem_list = arcade.SpriteList()
57+
self.coin_score = 0
58+
self.gem_score = 0
5059
self.score_display = arcade.Text(
51-
text="Score: 0",
60+
text="Coins: 0 | Gems: 0",
5261
x=10, y=WINDOW_HEIGHT - 30,
5362
color=arcade.color.WHITE, font_size=16,
5463
)
@@ -58,57 +67,79 @@ def setup(self):
5867
self.player_sprite = arcade.Sprite(img, scale=0.5)
5968
self.player_sprite.position = WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2
6069

61-
# Replace the default hitbox with a multi-region hitbox.
62-
# "body" is a box around the torso, "shield" extends to the right.
63-
# Collision detection automatically checks all regions.
70+
# Multi-region hitbox with collision channels:
71+
# "body" on channel 1 — collides with coins (also on channel 1)
72+
# "shield" on channel 2 — collides with gems (also on channel 2)
6473
self.player_sprite.hit_box = HitBox(
6574
{
6675
"body": [(-15, -48), (-15, 40), (15, 40), (15, -48)],
67-
"shield": [(15, -30), (15, 30), (45, 30), (45, -30)],
76+
"shield": {
77+
"points": [(35, -30), (35, 30), (65, 30), (65, -30)],
78+
"channel": channels(2),
79+
"mask": channels(2),
80+
},
6881
},
6982
position=self.player_sprite.position,
7083
scale=self.player_sprite.scale,
7184
angle=self.player_sprite.angle,
7285
)
7386

7487
self.player_list.append(self.player_sprite)
75-
self._spawn_coins(COIN_COUNT)
88+
self._spawn_items(self.coin_list, COIN_COUNT, is_gem=False)
89+
self._spawn_items(self.gem_list, GEM_COUNT, is_gem=True)
7690

77-
def _spawn_coins(self, count):
91+
def _spawn_items(self, sprite_list, count, is_gem):
7892
for _ in range(count):
79-
coin = arcade.Sprite(":resources:images/items/coinGold.png", scale=0.4)
93+
if is_gem:
94+
item = arcade.Sprite(
95+
":resources:images/items/gemBlue.png", scale=0.4,
96+
)
97+
# Gems are on channel 2 — only the shield can catch them
98+
item.hit_box = HitBox(
99+
item.hit_box.points,
100+
position=item.position,
101+
scale=item.scale,
102+
channel=channels(2),
103+
mask=channels(2),
104+
)
105+
else:
106+
item = arcade.Sprite(
107+
":resources:images/items/coinGold.png", scale=0.4,
108+
)
109+
# Coins keep the default channel 1 — only the body can catch them
80110

81111
# Spawn along a random edge
82112
side = random.randint(0, 3)
83113
if side == 0:
84-
coin.center_x = random.randrange(WINDOW_WIDTH)
85-
coin.center_y = WINDOW_HEIGHT + 20
114+
item.center_x = random.randrange(WINDOW_WIDTH)
115+
item.center_y = WINDOW_HEIGHT + 20
86116
elif side == 1:
87-
coin.center_x = random.randrange(WINDOW_WIDTH)
88-
coin.center_y = -20
117+
item.center_x = random.randrange(WINDOW_WIDTH)
118+
item.center_y = -20
89119
elif side == 2:
90-
coin.center_x = -20
91-
coin.center_y = random.randrange(WINDOW_HEIGHT)
120+
item.center_x = -20
121+
item.center_y = random.randrange(WINDOW_HEIGHT)
92122
else:
93-
coin.center_x = WINDOW_WIDTH + 20
94-
coin.center_y = random.randrange(WINDOW_HEIGHT)
123+
item.center_x = WINDOW_WIDTH + 20
124+
item.center_y = random.randrange(WINDOW_HEIGHT)
95125

96126
# Aim toward the center with some randomness
97127
target_x = WINDOW_WIDTH / 2 + random.randint(-200, 200)
98128
target_y = WINDOW_HEIGHT / 2 + random.randint(-200, 200)
99-
dx = target_x - coin.center_x
100-
dy = target_y - coin.center_y
129+
dx = target_x - item.center_x
130+
dy = target_y - item.center_y
101131
dist = math.hypot(dx, dy)
102132
if dist > 0:
103-
coin.change_x = (dx / dist) * COIN_SPEED
104-
coin.change_y = (dy / dist) * COIN_SPEED
133+
item.change_x = (dx / dist) * ITEM_SPEED
134+
item.change_y = (dy / dist) * ITEM_SPEED
105135

106-
self.coin_list.append(coin)
136+
sprite_list.append(item)
107137

108138
def on_draw(self):
109139
self.clear()
110140

111141
self.coin_list.draw()
142+
self.gem_list.draw()
112143
self.player_list.draw()
113144

114145
# Debug: draw each hitbox region in a different color
@@ -119,10 +150,12 @@ def on_draw(self):
119150
arcade.draw_line_strip(tuple(pts) + (pts[0],), color=color, line_width=2)
120151

121152
self.coin_list.draw_hit_boxes(color=arcade.color.YELLOW, line_thickness=1)
153+
self.gem_list.draw_hit_boxes(color=arcade.color.BLUE, line_thickness=1)
122154
self.score_display.draw()
123155

124156
arcade.draw_text(
125-
"Red = Body | Cyan = Shield | Arrow keys to move | A/D to rotate",
157+
"Red body (ch1) = coins | Cyan shield (ch2) = gems | "
158+
"Arrows to move | A/D to rotate",
126159
WINDOW_WIDTH / 2, 20,
127160
arcade.color.WHITE, font_size=12, anchor_x="center",
128161
)
@@ -165,27 +198,46 @@ def on_update(self, delta_time):
165198
if keys[arcade.key.D]:
166199
self.player_sprite.angle += 3.0
167200

168-
# Move coins
201+
# Move items
169202
self.coin_list.update()
203+
self.gem_list.update()
170204

171-
# Standard collision check — automatically tests all hitbox regions
172-
hit_list = arcade.check_for_collision_with_list(
205+
# Coins only collide with the body (channel 1)
206+
coin_hits = arcade.check_for_collision_with_list(
173207
self.player_sprite, self.coin_list
174208
)
175-
if hit_list:
176-
for coin in hit_list:
177-
coin.remove_from_sprite_lists()
178-
self.score += 1
179-
self.score_display.text = f"Score: {self.score}"
209+
for coin in coin_hits:
210+
coin.remove_from_sprite_lists()
211+
self.coin_score += 1
212+
213+
# Gems only collide with the shield (channel 2)
214+
gem_hits = arcade.check_for_collision_with_list(
215+
self.player_sprite, self.gem_list
216+
)
217+
for gem in gem_hits:
218+
gem.remove_from_sprite_lists()
219+
self.gem_score += 1
220+
221+
if coin_hits or gem_hits:
222+
self.score_display.text = (
223+
f"Coins: {self.coin_score} | Gems: {self.gem_score}"
224+
)
180225

181-
# Replace coins that left the screen
226+
# Replace items that left the screen
182227
margin = 100
183-
for coin in list(self.coin_list):
184-
if (coin.center_x < -margin or coin.center_x > WINDOW_WIDTH + margin
185-
or coin.center_y < -margin or coin.center_y > WINDOW_HEIGHT + margin):
186-
coin.remove_from_sprite_lists()
228+
for item in list(self.coin_list):
229+
if (item.center_x < -margin or item.center_x > WINDOW_WIDTH + margin
230+
or item.center_y < -margin or item.center_y > WINDOW_HEIGHT + margin):
231+
item.remove_from_sprite_lists()
232+
for item in list(self.gem_list):
233+
if (item.center_x < -margin or item.center_x > WINDOW_WIDTH + margin
234+
or item.center_y < -margin or item.center_y > WINDOW_HEIGHT + margin):
235+
item.remove_from_sprite_lists()
236+
187237
if len(self.coin_list) < COIN_COUNT:
188-
self._spawn_coins(COIN_COUNT - len(self.coin_list))
238+
self._spawn_items(self.coin_list, COIN_COUNT - len(self.coin_list), is_gem=False)
239+
if len(self.gem_list) < GEM_COUNT:
240+
self._spawn_items(self.gem_list, GEM_COUNT - len(self.gem_list), is_gem=True)
189241

190242

191243
def main():

arcade/hitbox/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
from arcade.types import Point2List
44

5-
from .base import HitBox, HitBoxAlgorithm, RawHitBox
5+
from .base import (
6+
DEFAULT_CHANNEL,
7+
DEFAULT_MASK,
8+
HitBox,
9+
HitBoxAlgorithm,
10+
RawHitBox,
11+
RawHitBoxRegion,
12+
channels,
13+
)
614
from .bounding_box import BoundingHitBoxAlgorithm
715

816
from .simple import SimpleHitBoxAlgorithm
@@ -60,6 +68,10 @@ def calculate_hit_box_points_detailed(
6068
"HitBoxAlgorithm",
6169
"HitBox",
6270
"RawHitBox",
71+
"RawHitBoxRegion",
72+
"DEFAULT_CHANNEL",
73+
"DEFAULT_MASK",
74+
"channels",
6375
"SimpleHitBoxAlgorithm",
6476
"PymunkHitBoxAlgorithm",
6577
"BoundingHitBoxAlgorithm",

0 commit comments

Comments
 (0)