Skip to content
Open
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
- Supports `hovered`, `pressed`, and `disabled` states with `on_click` event dispatch.
- Widget size defaults to the sprite's texture dimensions, overridable with explicit `width`/`height`.
- Added `TextPool` - provides a mechanism for caching Text objects for re-use
- GUI: Added an experimental animation API in `arcade.gui.experimental` — `UIAnimatedGroup` wraps a widget subtree and tweens its transform properties over time via `animate()`.
- `UIAnimatedGroup` caches its subtree into a surface and exposes non-layouting transform properties (`scale`, `angle`, `alpha`, `offset_x`, `offset_y`, `tint`); it is interactive, hit-testing `hovered`/`pressed`/`on_click` against its untransformed rect. `UIRenderGroup` is the non-interactive caching primitive.
- `animate()` supports multiple properties per call, sequencing via `then()`, repetition (`repeat`, `yoyo`), relative targets (`rel()`), easing, delays and `on_finish` callbacks.
- Animations are resolved per property: starting a new animation takes over its properties from running ones, other properties keep animating.
- Interpolates numbers, tuples, `Color` and vector types; non-interpolatable values snap at the end.
- The low level `Transition*` classes remain available for custom behavior.

### Fixes

- Fixed an issue where pixel scaling for high-dpi displays did not work correctly in web browsers via Pyodide. See [#2846](https://github.com/pythonarcade/arcade/pull/2846)
- Fixed issues with update/draw rate handling that changes with Pyglet 3, rates are now handled properly between desktop and browser. See [#2845](https://github.com/pythonarcade/arcade/pull/2845)
- Fixed caret behavior not responding appropriately when activating an input field. See [#2850](https://github.com/pythonarcade/arcade/pull/2850)
- GUI: Fixed `UILabel.update_font` always reporting a font change when the requested font was a fallback tuple (e.g. `UIFlatButton`'s default styles), which caused a full UI re-render every frame. This made widget-heavy UIs, such as the `exp_scroll_area` example, run at a few FPS.
## 4.0.0.dev4

### New Features
Expand Down
23 changes: 12 additions & 11 deletions arcade/draw/rect.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def draw_texture_rect(
# Clamp alpha to 0-255
alpha_normalized = max(0, min(255, alpha)) / 255.0

blend_state = ctx.is_enabled(ctx.BLEND)
if blend:
ctx.enable(ctx.BLEND)
else:
Expand Down Expand Up @@ -76,7 +77,9 @@ def draw_texture_rect(

geometry.render(program, mode=gl.TRIANGLE_STRIP, vertices=4)

if blend:
if blend_state:
ctx.enable(ctx.BLEND)
else:
ctx.disable(ctx.BLEND)


Expand Down Expand Up @@ -387,17 +390,15 @@ def draw_rect_filled(rect: Rect, color: RGBOrA255, tilt_angle: float = 0) -> Non
# Validate & normalize to a pass the shader an RGBA float uniform
color_normalized = Color.from_iterable(color).normalized

ctx.enable(ctx.BLEND)

# Pass data to the shader
program["color"] = color_normalized
program["shape"] = rect.width, rect.height, tilt_angle
buffer.orphan()
buffer.write(data=array.array("f", (rect.x, rect.y)))

geometry.render(program, instances=1)
# contextmanager will restore state which existed before
with ctx.enabled(ctx.BLEND):
# Pass data to the shader
program["color"] = color_normalized
program["shape"] = rect.width, rect.height, tilt_angle
buffer.orphan()
buffer.write(data=array.array("f", (rect.x, rect.y)))

ctx.disable(ctx.BLEND)
geometry.render(program, instances=1)


# These might be "oddly specific" and also needs docstrings. Disabling or 3.0.0
Expand Down
260 changes: 260 additions & 0 deletions arcade/examples/gui/exp_animations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
"""A game main menu with animated widgets.

This example combines two new GUI features:

- :class:`UIAnimatedGroup` renders a widget subtree into a cached surface,
which can be drawn with rotation, scaling, fading and tinting.
The wrapped widgets are only re-rendered when they actually change,
animating the group itself is basically free.
- Animations (:meth:`UIAnimatedGroup.animate`) tween properties over time and
support sequencing (``then()``), repetition (``repeat``, ``yoyo``) and
relative targets (``rel()``). Starting a new animation takes over its
properties from running ones, so state changes like hover just start
a new animation.

The menu shows:

- the whole menu popping in when the view is shown, and fading out on "Quit"
- a gently pulsing and swaying title
- buttons popping in staggered, popping and wiggling when hovered
- decorative spinning letter tiles
- a spinning "Play" button when clicked

If Arcade and Python are properly installed, you can run this example with:
python -m arcade.examples.gui.exp_animations
"""

from __future__ import annotations

from typing import Callable

import arcade
import pyglet
from arcade import uicolor
from arcade.anim import Easing
from arcade.gui import (
UIAnchorLayout,
UIBoxLayout,
UIFlatButton,
UILabel,
UIView,
UIWidget,
bind,
)
from arcade.gui.experimental import Animation, UIAnimatedGroup, rel
from arcade.types import Color

BACKGROUND = Color(110, 148, 132)
TILE_COLORS = [
uicolor.GREEN_GREEN_SEA,
uicolor.RED_ALIZARIN,
uicolor.YELLOW_SUN_FLOWER,
uicolor.DARK_BLUE_WET_ASPHALT,
]

IdleFactory = Callable[["AnimatedWidget"], Animation]
"""Starts an idle animation on a group and returns it, so it can be stopped."""


def pulse(scale=1.05, period=1.2) -> IdleFactory:
"""Idle animation: gently pulsing scale."""

def start(group: AnimatedWidget) -> Animation:
return group.animate(
scale=scale, duration=period / 2, ease=Easing.SINE, repeat=True, yoyo=True
)

return start


def sway(angle=2.0, period=2.4) -> IdleFactory:
"""Idle animation: swaying 0 -> angle -> -angle -> 0, forever."""

def start(group: AnimatedWidget) -> Animation:
return (
group.animate(angle=angle, duration=period / 4, ease=Easing.SINE, repeat=True)
.then(angle=-angle, duration=period / 2, ease=Easing.SINE)
.then(angle=0.0, duration=period / 4, ease=Easing.SINE)
)

return start


def spin(period=8.0) -> IdleFactory:
"""Idle animation: endless clockwise rotation."""

def start(group: AnimatedWidget) -> Animation:
return group.animate(angle=rel(-360), duration=period, repeat=True)

return start


class AnimatedWidget(UIAnimatedGroup):
"""A UIAnimatedGroup with little animation helpers.

Args:
child: The wrapped widget.
idle: Optional idle animation factory (or list of factories),
started after entrance and hover animations finish.
"""

def __init__(
self,
*,
child: UIWidget,
idle: IdleFactory | list[IdleFactory] | None = None,
):
super().__init__(child=child)
self._idle_factories = [idle] if callable(idle) else list(idle or [])
self._idle_animations: list[Animation] = []

def start_idle(self):
"""(Re-)start the idle animations, unless the group is hovered."""
if self.hovered:
return
self.stop_idle()
self._idle_animations = [factory(self) for factory in self._idle_factories]

def stop_idle(self):
for animation in self._idle_animations:
animation.stop()
self._idle_animations = []

def pop_in(self, delay: float = 0.0):
"""Scale and fade in, then continue with the idle animation."""
self.scale = 0.0
self.alpha = 0
self.animate(scale=1.0, duration=0.5, delay=delay, ease=Easing.BACK_OUT).on_finish(
self.start_idle
)
self.animate(alpha=255, duration=0.35, delay=delay)

def pop_on_hover(self, scale: float = 1.08, angle: float = 2.5):
"""Pop and wiggle while the group is hovered."""

def on_hover_change():
if self.hovered:
self.stop_idle()
self.animate(scale=scale, duration=0.15, ease=Easing.BACK_OUT)
self.animate(angle=angle, duration=0.06).then(angle=-angle, duration=0.12).then(
angle=0.0, duration=0.06
)
# in case an interrupted entrance left the group transparent
self.animate(alpha=255, duration=0.1)
else:
self.animate(scale=1.0, angle=0.0, duration=0.15).on_finish(self.start_idle)

bind(self, "hovered", on_hover_change)


class MainMenuView(UIView):
def __init__(self):
super().__init__()
# self.ui._pixelated = True

self.background_color = BACKGROUND

root = UIAnchorLayout(size_hint=(1, 1))

# title, gently pulsing and swaying
title = UILabel(
"New GUI\nAnimations",
font_size=72,
bold=True,
multiline=True,
align="center",
text_color=uicolor.DARK_BLUE_MIDNIGHT_BLUE,
)
self.title = AnimatedWidget(child=title, idle=[pulse(1.04, 2.4), sway(2, 2.4)])
root.add(self.title, anchor_x="center", anchor_y="top", align_y=-60)

# main menu buttons, the resume button pulses to attract attention
resume = AnimatedWidget(
child=UIFlatButton(text="Resume", width=240, height=50),
idle=pulse(1.05, 1.2),
)
play = AnimatedWidget(child=UIFlatButton(text="Play", width=240, height=50))
quit_ = AnimatedWidget(
child=UIFlatButton(text="Quit", width=240, height=50, style=UIFlatButton.STYLE_RED)
)
self.menu_buttons = [resume, play, quit_]

menu = UIBoxLayout(space_between=12)
for group in self.menu_buttons:
group.pop_on_hover()
menu.add(group)
root.add(menu, anchor_x="center", anchor_y="center", align_y=-40)

# side menu buttons
self.side_buttons = []
side = UIBoxLayout(space_between=8)
for text in ("Leaderboard", "Librarium", "Settings", "Credits"):
group = AnimatedWidget(child=UIFlatButton(text=text, width=180, height=36))
group.pop_on_hover(scale=1.1)
self.side_buttons.append(group)
side.add(group)
root.add(side, anchor_x="left", anchor_y="bottom", align_x=40, align_y=40)

# decorative letter tiles, slowly spinning or swaying
self.tiles = []
for letter, color, anchor, align, idle in [
("N", TILE_COLORS[0], ("left", "top"), (140, -90), spin(9)),
("E", TILE_COLORS[1], ("left", "center"), (230, 30), sway(14, 4)),
("V", TILE_COLORS[2], ("right", "top"), (-380, -10), sway(-10, 5)),
("B", TILE_COLORS[3], ("right", "bottom"), (-200, 20), spin(12)),
]:
label = UILabel(letter, font_size=26, bold=True, text_color=uicolor.WHITE_CLOUDS)
tile = (
label.with_padding(all=12)
.with_background(color=color)
.with_border(width=3, color=uicolor.DARK_BLUE_MIDNIGHT_BLUE)
)
group = AnimatedWidget(child=tile, idle=idle)
self.tiles.append(group)
root.add(
group, anchor_x=anchor[0], anchor_y=anchor[1], align_x=align[0], align_y=align[1]
)

# the whole menu is wrapped in another group, to fade it out on quit
self.root_group = AnimatedWidget(child=root)
self.add_widget(self.root_group)

play.child.on_click = self.on_play
quit_.child.on_click = self.on_quit
self._play_group = play

def on_show_view(self):
super().on_show_view()

# staggered entrance animation
self.title.pop_in(delay=0.1)
for i, group in enumerate(self.menu_buttons):
group.pop_in(delay=0.3 + i * 0.1)
for i, group in enumerate(self.side_buttons):
group.pop_in(delay=0.6 + i * 0.08)
for i, group in enumerate(self.tiles):
group.pop_in(delay=0.9 + i * 0.1)

def on_play(self, event):
"""Spin the play button once."""
self._play_group.animate(angle=rel(-360), duration=0.6, ease=Easing.CUBIC)

def on_quit(self, event):
"""Scale and fade out the whole menu, then close the window."""
group = self.root_group
group.animate(alpha=0, duration=0.5, ease=Easing.SINE_IN)
group.animate(scale=0.8, duration=0.5, ease=Easing.SINE_IN).then(duration=0.1).on_finish(
arcade.exit
)


def main():
window = arcade.Window(1280, 720, "GUI Example: Animated Main Menu", resizable=True)
window.show_view(MainMenuView())
arcade.run()


if __name__ == "__main__":
pyglet.options.text_antialiasing = False

main()
Loading
Loading