Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 252 additions & 0 deletions arcade/examples/sprite_multi_hitbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
"""
Sprite Multi-Region Hit Boxes with Collision Channels

Demonstrates sprites with multiple hit box regions and per-region
collision channels. The player sprite has a "body" region on channel 1
and a "shield" region on channel 2.

Gold coins are on channel 1 and can only be collected by the body.
Blue gems are on channel 2 and can only be collected by the shield.
Coins pass through the shield, and gems pass through the body.

Hit box outlines are drawn for visual debugging. Use A/D to rotate
the player and see both regions rotate together.

If Python and Arcade are installed, this example can be run from the
command line with:
python -m arcade.examples.sprite_multi_hitbox
"""

import random
import math
import arcade
from arcade.hitbox import HitBox, channels

WINDOW_WIDTH = 1280
WINDOW_HEIGHT = 720
WINDOW_TITLE = "Multi-Region Hit Box Example"

PLAYER_SPEED = 5.0
ITEM_SPEED = 2.0
COIN_COUNT = 15
GEM_COUNT = 10


class GameView(arcade.View):

def __init__(self):
super().__init__()
self.player_sprite = None
self.player_list = None
self.coin_list = None
self.gem_list = None
self.coin_score = 0
self.gem_score = 0
self.score_display = None
self.background_color = arcade.csscolor.DARK_SLATE_GRAY

self.left_pressed = False
self.right_pressed = False
self.up_pressed = False
self.down_pressed = False

def setup(self):
self.player_list = arcade.SpriteList()
self.coin_list = arcade.SpriteList()
self.gem_list = arcade.SpriteList()
self.coin_score = 0
self.gem_score = 0
self.score_display = arcade.Text(
text="Coins: 0 | Gems: 0",
x=10, y=WINDOW_HEIGHT - 30,
color=arcade.color.WHITE, font_size=16,
)

# Create the player sprite
img = ":resources:images/animated_characters/female_person/femalePerson_idle.png"
self.player_sprite = arcade.Sprite(img, scale=0.5)
self.player_sprite.position = WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2

# Multi-region hitbox with collision channels:
# "body" on channel 1 — collides with coins (also on channel 1)
# "shield" on channel 2 — collides with gems (also on channel 2)
self.player_sprite.hit_box = HitBox(
{
"body": [(-15, -48), (-15, 40), (15, 40), (15, -48)],
"shield": {
"points": [(35, -30), (35, 30), (65, 30), (65, -30)],
"channel": channels(2),
"mask": channels(2),
},
},
position=self.player_sprite.position,
scale=self.player_sprite.scale,
angle=self.player_sprite.angle,
)

self.player_list.append(self.player_sprite)
self._spawn_items(self.coin_list, COIN_COUNT, is_gem=False)
self._spawn_items(self.gem_list, GEM_COUNT, is_gem=True)

def _spawn_items(self, sprite_list, count, is_gem):
for _ in range(count):
if is_gem:
item = arcade.Sprite(
":resources:images/items/gemBlue.png", scale=0.4,
)
# Gems are on channel 2 — only the shield can catch them
item.hit_box = HitBox(
item.hit_box.points,
position=item.position,
scale=item.scale,
channel=channels(2),
mask=channels(2),
)
else:
item = arcade.Sprite(
":resources:images/items/coinGold.png", scale=0.4,
)
# Coins keep the default channel 1 — only the body can catch them

# Spawn along a random edge
side = random.randint(0, 3)
if side == 0:
item.center_x = random.randrange(WINDOW_WIDTH)
item.center_y = WINDOW_HEIGHT + 20
elif side == 1:
item.center_x = random.randrange(WINDOW_WIDTH)
item.center_y = -20
elif side == 2:
item.center_x = -20
item.center_y = random.randrange(WINDOW_HEIGHT)
else:
item.center_x = WINDOW_WIDTH + 20
item.center_y = random.randrange(WINDOW_HEIGHT)

# Aim toward the center with some randomness
target_x = WINDOW_WIDTH / 2 + random.randint(-200, 200)
target_y = WINDOW_HEIGHT / 2 + random.randint(-200, 200)
dx = target_x - item.center_x
dy = target_y - item.center_y
dist = math.hypot(dx, dy)
if dist > 0:
item.change_x = (dx / dist) * ITEM_SPEED
item.change_y = (dy / dist) * ITEM_SPEED

sprite_list.append(item)

def on_draw(self):
self.clear()

self.coin_list.draw()
self.gem_list.draw()
self.player_list.draw()

# Debug: draw each hitbox region in a different color
player_hb = self.player_sprite.hit_box
for region_name in player_hb.region_names:
pts = player_hb.get_adjusted_points(region_name)
color = arcade.color.RED if region_name == "body" else arcade.color.CYAN
arcade.draw_line_strip(tuple(pts) + (pts[0],), color=color, line_width=2)

self.coin_list.draw_hit_boxes(color=arcade.color.YELLOW, line_thickness=1)
self.gem_list.draw_hit_boxes(color=arcade.color.BLUE, line_thickness=1)
self.score_display.draw()

arcade.draw_text(
"Red body (ch1) = coins | Cyan shield (ch2) = gems | "
"Arrows to move | A/D to rotate",
WINDOW_WIDTH / 2, 20,
arcade.color.WHITE, font_size=12, anchor_x="center",
)

def on_key_press(self, key, modifiers):
if key in (arcade.key.UP, arcade.key.W):
self.up_pressed = True
elif key in (arcade.key.DOWN, arcade.key.S):
self.down_pressed = True
elif key == arcade.key.LEFT:
self.left_pressed = True
elif key == arcade.key.RIGHT:
self.right_pressed = True

def on_key_release(self, key, modifiers):
if key in (arcade.key.UP, arcade.key.W):
self.up_pressed = False
elif key in (arcade.key.DOWN, arcade.key.S):
self.down_pressed = False
elif key == arcade.key.LEFT:
self.left_pressed = False
elif key == arcade.key.RIGHT:
self.right_pressed = False

def on_update(self, delta_time):
# Move the player
if self.up_pressed:
self.player_sprite.center_y += PLAYER_SPEED
if self.down_pressed:
self.player_sprite.center_y -= PLAYER_SPEED
if self.left_pressed:
self.player_sprite.center_x -= PLAYER_SPEED
if self.right_pressed:
self.player_sprite.center_x += PLAYER_SPEED

# Rotate with A/D
keys = self.window.keyboard
if keys[arcade.key.A]:
self.player_sprite.angle -= 3.0
if keys[arcade.key.D]:
self.player_sprite.angle += 3.0

# Move items
self.coin_list.update()
self.gem_list.update()

# Coins only collide with the body (channel 1)
coin_hits = arcade.check_for_collision_with_list(
self.player_sprite, self.coin_list
)
for coin in coin_hits:
coin.remove_from_sprite_lists()
self.coin_score += 1

# Gems only collide with the shield (channel 2)
gem_hits = arcade.check_for_collision_with_list(
self.player_sprite, self.gem_list
)
for gem in gem_hits:
gem.remove_from_sprite_lists()
self.gem_score += 1

if coin_hits or gem_hits:
self.score_display.text = (
f"Coins: {self.coin_score} | Gems: {self.gem_score}"
)

# Replace items that left the screen
margin = 100
for item in list(self.coin_list):
if (item.center_x < -margin or item.center_x > WINDOW_WIDTH + margin
or item.center_y < -margin or item.center_y > WINDOW_HEIGHT + margin):
item.remove_from_sprite_lists()
for item in list(self.gem_list):
if (item.center_x < -margin or item.center_x > WINDOW_WIDTH + margin
or item.center_y < -margin or item.center_y > WINDOW_HEIGHT + margin):
item.remove_from_sprite_lists()

if len(self.coin_list) < COIN_COUNT:
self._spawn_items(self.coin_list, COIN_COUNT - len(self.coin_list), is_gem=False)
if len(self.gem_list) < GEM_COUNT:
self._spawn_items(self.gem_list, GEM_COUNT - len(self.gem_list), is_gem=True)


def main():
window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)
game = GameView()
game.setup()
window.show_view(game)
arcade.run()


if __name__ == "__main__":
main()
3 changes: 1 addition & 2 deletions arcade/gui/widgets/dropdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,7 @@ def do_layout(self):
overlay_w = self.width + scroll_bar_w

overlay.rect = (
overlay.rect
.resize(overlay_w, visible_h)
overlay.rect.resize(overlay_w, visible_h)
.align_top(self.bottom - 2)
.align_left(self._default_button.left)
)
Expand Down
2 changes: 1 addition & 1 deletion arcade/gui/widgets/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import warnings
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Literal, TypeVar
from types import EllipsisType
from typing import Literal, TypeVar

from typing_extensions import override

Expand Down
19 changes: 16 additions & 3 deletions arcade/hitbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

from arcade.types import Point2List

from .base import HitBox, HitBoxAlgorithm, RotatableHitBox
from .base import (
DEFAULT_CHANNEL,
DEFAULT_MASK,
HitBox,
HitBoxAlgorithm,
RawHitBox,
RawHitBoxRegion,
channels,
)
from .bounding_box import BoundingHitBoxAlgorithm

from .simple import SimpleHitBoxAlgorithm
Expand All @@ -13,7 +21,8 @@
#: The detailed hit box algorithm. This depends on pymunk and will fallback to the simple algorithm.
try:
from .pymunk import PymunkHitBoxAlgorithm
algo_detailed = PymunkHitBoxAlgorithm()

algo_detailed: HitBoxAlgorithm = PymunkHitBoxAlgorithm()
except ImportError:
print("WARNING: Running without PyMunk. The detailed hitbox algorithm will fallback to simple")
algo_detailed = SimpleHitBoxAlgorithm()
Expand Down Expand Up @@ -58,7 +67,11 @@ def calculate_hit_box_points_detailed(
__all__ = [
"HitBoxAlgorithm",
"HitBox",
"RotatableHitBox",
"RawHitBox",
"RawHitBoxRegion",
"DEFAULT_CHANNEL",
"DEFAULT_MASK",
"channels",
"SimpleHitBoxAlgorithm",
"PymunkHitBoxAlgorithm",
"BoundingHitBoxAlgorithm",
Expand Down
Loading
Loading