Skip to content

Commit

Permalink
feat(gui): camera in ui (#2193)
Browse files Browse the repository at this point in the history
* feat(gui): camera in ui with an example
* fmt(gui): fmt gui code
* fix(gui): uilabel size_hints, extend gui camera example
  • Loading branch information
eruvanos authored Jul 3, 2024
1 parent e5962e3 commit 6477b48
Show file tree
Hide file tree
Showing 9 changed files with 416 additions and 77 deletions.
4 changes: 3 additions & 1 deletion arcade/gui/examples/exp_hidden_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ def __init__(self):
self.username_input = grid.add(UIInputText(height=25), col_num=1, row_num=0).with_border()

grid.add(UILabel(text="Password:"), col_num=0, row_num=1)
self.password_input = grid.add(UIPasswordInput(height=25), col_num=1, row_num=1).with_border()
self.password_input = grid.add(
UIPasswordInput(height=25), col_num=1, row_num=1
).with_border()

self.login_button = grid.add(UIFlatButton(text="Login"), col_num=0, row_num=2, col_span=2)
self.login_button.on_click = self.on_login
Expand Down
257 changes: 257 additions & 0 deletions arcade/gui/examples/gui_and_camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
"""
Arrange widgets in vertical or horizontal lines with UIBoxLayout
The direction UIBoxLayout follows is controlled by the `vertical` keyword
argument. It is True by default. Pass False to it to arrange elements in
a horizontal line.
If arcade and Python are properly installed, you can run this example with:
python -m arcade.gui.examples.gui_and_camera
"""

from __future__ import annotations

import math
import random
from typing import Optional

import arcade
from arcade.gui import UIView, UIFlatButton, UIOnClickEvent, UILabel, UIBoxLayout
from arcade.gui.widgets.layout import UIAnchorLayout


class MyCoinGame(UIView):
"""
Main view of the game. This class is a subclass of UIView, which provides
basic GUI setup. We add UIManager to the view under `self.ui`.
The example showcases how to:
- use UIView to setup a basic GUI
- add a button to the view and connect it to a function
- use camera to move the view
"""

def __init__(self):
super().__init__()

# basic camera setup
self.keys = set()
self.ingame_camera = arcade.Camera2D()
self.ingame_camera.bottom_left = 100, 100

# in-game counter
self._total_time = 0
self._game_duration = 60
self._game_over = False
self._last_coin_spawn = 0
self._coin_spawn_delay = 3
self._coins_collected = 0

# upgradable player values
self._player_speed = 5

# setup in-game objects
self.sprites = arcade.SpriteList()

self.game_area = arcade.SpriteSolidColor(
width=1300,
height=730,
color=arcade.color.GRAY_ASPARAGUS,
center_x=1280 / 2,
center_y=720 / 2,
)

self.sprites.append(self.game_area)

self.player = arcade.Sprite(
":resources:/images/animated_characters/female_adventurer/femaleAdventurer_idle.png",
scale=0.5,
center_x=1280 / 2,
center_y=720 / 2,
)
self.sprites.append(self.player)

self.coins = arcade.SpriteList()
for i in range(12):
# place coins in a circle around the player, radius =100
coin = arcade.Sprite(
":resources:images/items/coinGold.png",
scale=0.5,
center_x=1280 / 2 + 200 * math.cos(math.radians(i * 40)),
center_y=720 / 2 + 200 * math.sin(math.radians(i * 40)),
)
self.coins.append(coin)

# UI setup, we use UIView, which automatically adds UIManager as self.ui
anchor = self.ui.add(UIAnchorLayout())

shop_buttons = anchor.add(
UIBoxLayout(vertical=False, space_between=10),
anchor_x="center",
anchor_y="bottom",
align_y=10,
)

# speed upgrade button
speed_upgrade = UIFlatButton(text="Upgrade Speed (5C)", width=200, height=40)
shop_buttons.add(speed_upgrade)

@speed_upgrade.event("on_click")
def upgrade_speed(event: UIOnClickEvent):
cost = self._player_speed
if self._coins_collected >= cost:
self._coins_collected -= cost
self._player_speed += 1
speed_upgrade.text = f"Update Speed ({self._player_speed}C)"
print("Speed upgraded")

# update spawn rate button
spawn_rate_upgrade = UIFlatButton(text="Upgrade spawn rate: 10C", width=300, height=40)
shop_buttons.add(spawn_rate_upgrade)

@spawn_rate_upgrade.event("on_click")
def upgrade_spawn_rate(event: UIOnClickEvent):
cost = 10
if self._coins_collected >= cost:
self._coins_collected -= cost
self._coin_spawn_delay -= 0.5
print("Spawn rate upgraded")

# position top center, with a 40px offset
self.out_of_game_area = anchor.add(
UILabel(text="Out of game area", font_size=32),
anchor_x="center",
anchor_y="top",
align_y=-40,
)
self.out_of_game_area.visible = False

self.coin_counter = anchor.add(
UILabel(text="Collected coins 0", size_hint=(0, 0)),
anchor_x="left",
anchor_y="top",
align_y=-10,
align_x=10,
)
self.coin_counter.with_background(
color=arcade.color.TRANSPARENT_BLACK
# giving a background will make the label way more performant,
# because it will not re-render the whole UI after a text change
)

# Game timer
self.timer = anchor.add(
UILabel(
text="Time 30.0",
font_size=15,
size_hint=(0, 0), # take the whole width to prevent linebreaks
),
anchor_x="center",
anchor_y="top",
align_y=-10,
align_x=-10,
)
self.timer.with_background(color=arcade.color.TRANSPARENT_BLACK)

def on_draw_before_ui(self):
self.ingame_camera.use() # use the in-game camera to draw in-game objects
self.sprites.draw()
self.coins.draw()

def on_update(self, delta_time: float) -> Optional[bool]:
if self._total_time > self._game_duration:
# ad new UI label to show the end of the game
game_over_text = self.ui.add(
UILabel(
text="End of game!\n"
f"You achieved {self._coins_collected} coins!\n"
"Press ESC to exit.\n"
"Use ENTER to restart.",
font_size=32,
bold=True,
multiline=True,
align="center",
text_color=arcade.color.WHITE,
size_hint=(0, 0),
),
)
game_over_text.with_padding(all=10)
game_over_text.with_background(color=arcade.types.Color(50, 50, 50, 120))
game_over_text.center_on_screen()

return True

self._total_time += delta_time
self._last_coin_spawn += delta_time

# update the timer
self.timer.text = f"Time {self._game_duration - self._total_time:.1f}"

# spawn new coins
if self._last_coin_spawn > self._coin_spawn_delay:
coin = arcade.Sprite(
":resources:images/items/coinGold.png",
scale=0.5,
center_x=random.randint(0, 1280),
center_y=random.randint(0, 720),
)
self.coins.append(coin)
self._last_coin_spawn -= self._coin_spawn_delay

# move the player sprite
if {arcade.key.LEFT, arcade.key.A} & self.keys:
self.player.left -= self._player_speed
if {arcade.key.RIGHT, arcade.key.D} & self.keys:
self.player.left += self._player_speed
if {arcade.key.UP, arcade.key.W} & self.keys:
self.player.top += self._player_speed
if {arcade.key.DOWN, arcade.key.S} & self.keys:
self.player.top -= self._player_speed

# move the camera with the player
self.ingame_camera.position = self.player.position

# collect coins
collisions = self.player.collides_with_list(self.coins)
for coin in collisions:
coin.remove_from_sprite_lists()
self._coins_collected += 1
print("Coin collected")

# update the coin counter
self.coin_counter.text = f"Collected coins {self._coins_collected}"

# inform player if they are out of the game area
if not self.player.collides_with_sprite(self.game_area):
self.out_of_game_area.visible = True
else:
self.out_of_game_area.visible = False

# slide in the UI from bottom, until total time reaches 2 seconds
progress = min(1.0, self._total_time / 2)
self.ui.camera.bottom_left = (0, 50 * (1 - progress))

return False

def on_key_press(self, symbol: int, modifiers: int) -> Optional[bool]:
self.keys.add(symbol)

if symbol == arcade.key.ESCAPE:
arcade.close_window()
if symbol == arcade.key.ENTER:
self.window.show_view(MyCoinGame())

return False

def on_key_release(self, symbol: int, modifiers: int) -> Optional[bool]:
if symbol in self.keys:
self.keys.remove(symbol)
return False


if __name__ == "__main__":
window = arcade.Window(1280, 720, "CoinGame Example", resizable=True)
window.background_color = arcade.color.DARK_BLUE_GRAY
window.show_view(MyCoinGame())
window.run()
35 changes: 24 additions & 11 deletions arcade/gui/ui_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,15 @@ def on_draw():

def __init__(self, window: Optional[arcade.Window] = None):
super().__init__()

self.window = window or arcade.get_window()
self._surfaces: dict[int, Surface] = {}
self.children: dict[int, list[UIWidget]] = defaultdict(list)
self._requires_render = True
self.camera = arcade.Camera2D()
self._render_to_surface_camera = arcade.Camera2D()
# this camera is used for rendering the UI and should not be changed by the user

self.register_event_type("on_event")

def add(self, widget: W, *, index=None, layer=0) -> W:
Expand Down Expand Up @@ -305,7 +310,6 @@ def on_update(self, time_delta):
return self.dispatch_ui_event(UIOnUpdateEvent(self, time_delta))

def draw(self) -> None:
current_cam = self.window.current_camera
"""
Will draw all widgets to the window.
Expand All @@ -323,17 +327,16 @@ def draw(self) -> None:
self.execute_layout()

ctx = self.window.ctx
with ctx.enabled(ctx.BLEND):
with ctx.enabled(ctx.BLEND), self._render_to_surface_camera.activate():
self._do_render()

# Correct that the ui changes the currently active camera.
current_cam.use()

# Draw layers
with ctx.enabled(ctx.BLEND):
layers = sorted(self.children.keys())
for layer in layers:
self._get_surface(layer).draw()
with self.camera.activate():
# Draw layers
with ctx.enabled(ctx.BLEND):
layers = sorted(self.children.keys())
for layer in layers:
self._get_surface(layer).draw()

def adjust_mouse_coordinates(self, x: float, y: float) -> tuple[float, float]:
"""
Expand All @@ -343,7 +346,7 @@ def adjust_mouse_coordinates(self, x: float, y: float) -> tuple[float, float]:
It uses the internal camera's map_coordinate methods, and should work with
all transformations possible with the basic orthographic camera.
"""
x_, y_, *c = self.window.current_camera.unproject((x, y))
x_, y_, *c = self.camera.unproject((x, y)) # convert screen to ui coordinates
return x_, y_

def on_event(self, event) -> Union[bool, None]:
Expand All @@ -360,7 +363,7 @@ def dispatch_ui_event(self, event):

def on_mouse_motion(self, x: int, y: int, dx: int, dy: int):
x_, y_ = self.adjust_mouse_coordinates(x, y)
return self.dispatch_ui_event(UIMouseMovementEvent(self, round(x_), round(y), dx, dy))
return self.dispatch_ui_event(UIMouseMovementEvent(self, round(x_), round(y_), dx, dy))

def on_mouse_press(self, x: int, y: int, button: int, modifiers: int):
x_, y_ = self.adjust_mouse_coordinates(x, y)
Expand Down Expand Up @@ -402,6 +405,16 @@ def on_text_motion_select(self, motion):
return self.dispatch_ui_event(UITextMotionSelectEvent(self, motion))

def on_resize(self, width, height):
# resize ui camera
bottom_left = self.camera.bottom_left
self.camera.match_screen()
self.camera.bottom_left = bottom_left

# resize render to surface camera
bottom_left = self._render_to_surface_camera.bottom_left
self._render_to_surface_camera.match_screen()
self._render_to_surface_camera.bottom_left = bottom_left

scale = self.window.get_pixel_ratio()
for surface in self._surfaces.values():
surface.resize(size=(width, height), pixel_ratio=scale)
Expand Down
16 changes: 15 additions & 1 deletion arcade/gui/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ class UIView(View):
This is a convenience class, which adds the UIManager to the view under `self.ui`.
The UIManager is enabled when the view is shown and disabled when the view is hidden.
This class provides two draw callbacks: on_draw_before_ui and on_draw_after_ui.
Use these to draw custom elements before or after the UI elements are drawn.
If you override ``on_show_view`` or ``on_show_view``,
don't forget to call super().on_show_view() or super().on_hide_view().
Expand All @@ -24,5 +27,16 @@ def on_hide_view(self):
self.ui.disable()

def on_draw(self):
"""In case of subclassing super().on_draw() should be called last."""
"""To subclass UIView and add custom drawing, override on_draw_before_ui and on_draw_after_ui."""
self.clear()
self.on_draw_before_ui()
self.ui.draw()
self.on_draw_after_ui()

def on_draw_before_ui(self):
"""Use this method to draw custom elements before the UI elements are drawn."""
pass

def on_draw_after_ui(self):
"""Use this method to draw custom elements after the UI elements are drawn."""
pass
Loading

0 comments on commit 6477b48

Please sign in to comment.