Skip to content

Commit

Permalink
feat: Add letter shooter example game. (#86)
Browse files Browse the repository at this point in the history
* feat: Add letter shooter example game.

* fix: Linter moans

* fix: Linter moans
  • Loading branch information
MartinHowarth authored Feb 6, 2020
1 parent 351c2e0 commit 501ac55
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 49 deletions.
12 changes: 6 additions & 6 deletions examples/calculator.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
"""Example of a simple calculator written using shimmer."""

import cocos
from typing import Optional, List, Callable

from pyglet.window import key
from typing import Optional, List, Callable

from shimmer.display.components.box_layout import create_box_layout, BoxLayoutDefinition
from shimmer.display.widgets.button import ButtonDefinition, Button
from shimmer.display.widgets.window import WindowDefinition, Window
from shimmer.display.widgets.text_box import TextBoxDefinition, TextBox
import cocos
from shimmer.display.alignment import LeftTop
from shimmer.display.components.box_layout import create_box_layout, BoxLayoutDefinition
from shimmer.display.keyboard import (
KeyboardActionDefinition,
KeyboardHandlerDefinition,
KeyboardHandler,
ChordDefinition,
)
from shimmer.display.widgets.button import ButtonDefinition, Button
from shimmer.display.widgets.text_box import TextBoxDefinition, TextBox
from shimmer.display.widgets.window import WindowDefinition, Window


class Calculator(Window):
Expand Down
92 changes: 92 additions & 0 deletions examples/letter_shooter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""
An example of a point-and-shoot game where you have to shoot boxes with the correct letter.
This shows how to make use of focus-on-hover behaviour and keyboard handling.
"""
from dataclasses import dataclass, field
from random import randint, choice
from string import ascii_lowercase

import cocos
from shimmer.display.components.box import Box, BoxDefinition
from shimmer.display.components.focus import (
FocusBoxDefinition,
make_focusable,
EVENT_HANDLED,
)
from shimmer.display.data_structures import Color
from shimmer.display.keyboard import add_simple_keyboard_handler
from shimmer.display.widgets.text_box import TextBoxDefinition, TextBox


@dataclass(frozen=True)
class TargetDefinition(BoxDefinition):
"""Definition of a target in this game."""

key: str = field(default="a")


class Target(Box):
"""
A Target that can be destroyed by shooting it with the keyboard.
More specifically, this requires the user to move the mouse over the target, and then press
the correct keyboard button.
"""

def __init__(self, definition: TargetDefinition):
"""Create a new Target."""
super(Target, self).__init__(definition)
# Add a TextBox to display the required letter.
self.text_box = TextBox(TextBoxDefinition(text=definition.key.upper()))
self.add(self.text_box)
# Position the text box in the center of this target.
self.text_box.align_anchor_with_other_anchor(self)

# Add a keyboard handler to listen for keyboard events.
self.keyboard_handler = add_simple_keyboard_handler(
self, definition.key, self._on_correct_keypress
)

# Add a focus box to make this target only listen to keyboard events when the mouse is
# inside it. Use the "focus_on_hover" option to gain/lose focus when the mouse enters/leaves
# the box.
self.focus_box = make_focusable(self, FocusBoxDefinition(focus_on_hover=True))

def _on_correct_keypress(self) -> bool:
"""On correct keypress, kill this target and create a new one."""
self.kill()
self.parent.add(create_random_target())
return EVENT_HANDLED


def create_random_target() -> Target:
"""Create a Target with random size, position, color and letter."""
window_width, window_height = cocos.director.director.get_window_size()
max_target_width, max_target_height = 300, 300
target_definition = TargetDefinition(
width=randint(30, max_target_width),
height=randint(30, max_target_height),
background_color=Color(randint(50, 255), randint(50, 255), randint(50, 255)),
key=choice(ascii_lowercase),
)
x, y = (
randint(0, window_width - max_target_width),
randint(0, window_height - max_target_height),
)

target = Target(target_definition)
target.position = x, y
return target


def main():
"""Run the Letter Shooter game."""
cocos.director.director.init()
initial_target = create_random_target()
scene = cocos.scene.Scene(initial_target)
cocos.director.director.run(scene)


if __name__ == "__main__":
main()
73 changes: 40 additions & 33 deletions shimmer/display/components/focus.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import logging
from dataclasses import dataclass, replace, field
from typing import List, Optional, Callable, Type, cast
from typing import List, Optional, Callable, Type

import cocos
from .box import Box
Expand Down Expand Up @@ -127,11 +127,17 @@ class FocusBoxDefinition(MouseBoxDefinition):
:param on_take_focus: Called when the FocusBox gains focus.
:param on_lose_focus: Called when the FocusBox loses focus.
:param focus_on_click: If True then focus is gained when the Box is clicked on, and lost when
a click occurs outside of the box.
:param focus_on_hover: If True then focus is gained when the Box is hovered over, and lost when
the cursor leaves the box.
:param focus_stack: The focus stack to use. Defaults to the global singleton.
"""

on_take_focus: Optional[Callable[[], None]] = None
on_lose_focus: Optional[Callable[[], None]] = None
focus_on_click: bool = True
focus_on_hover: bool = False
focus_stack: _FocusStackHandler = field(default=FocusStackHandler)


Expand All @@ -146,9 +152,16 @@ def __init__(self, definition: FocusBoxDefinition):
:param definition: BoxDefinition defining the shape of the focus box.
"""
definition = replace(
definition, on_press=self.on_click, on_press_outside=self.on_click_outside
)
if definition.focus_on_click:
definition = replace(
definition,
on_press=self.on_click,
on_press_outside=self.on_click_outside,
)
if definition.focus_on_hover:
definition = replace(
definition, on_hover=self.on_hover, on_unhover=self.on_unhover
)
super(FocusBox, self).__init__(definition)
self.definition: FocusBoxDefinition = self.definition
self._is_focused: bool = False
Expand Down Expand Up @@ -229,30 +242,24 @@ def on_click_outside(
self.lose_focus()
return EVENT_UNHANDLED

def on_hover(
self, box: "MouseBox", x: int, y: int, dx: int, dy: int
) -> Optional[bool]:
"""Take focus when this Box is hovered over."""
self.take_focus()
return EVENT_UNHANDLED

@dataclass(frozen=True)
class KeyboardFocusBoxDefinition(FocusBoxDefinition):
"""
Definition of a FocusBox that focuses and associated KeyboardHandler when it is focused.
This allows for a Box (and its children) to selectively receive keyboard events depending
on whether they are focused or not.
:param keyboard_handler: The KeyboardHandler to control the focus state of.
If None, then a keyboard handler that is a sibling of this node will be used.
"""

keyboard_handler: Optional[KeyboardHandler] = None
def on_unhover(
self, box: "MouseBox", x: int, y: int, dx: int, dy: int
) -> Optional[bool]:
"""Lose focus when the cursor leaves the Box."""
self.lose_focus()
return EVENT_UNHANDLED


class KeyboardFocusBox(FocusBox):
"""A mouse box that when clicked causes its parent to take precedence on keyboard events."""

def __init__(self, definition: KeyboardFocusBoxDefinition):
"""Create a new KeyboardFocusBox."""
super(KeyboardFocusBox, self).__init__(definition)
self.definition = cast(KeyboardFocusBoxDefinition, self.definition)

@staticmethod
def _set_focused_if_is_keyboard_handler(node: cocos.cocosnode.CocosNode) -> None:
"""Determine if the given node is a keyboard handler. If so, make it take focus."""
Expand Down Expand Up @@ -322,8 +329,7 @@ def on_click(

def make_focusable(
box: Box,
on_take_focus: Optional[Callable[[], None]] = None,
on_lose_focus: Optional[Callable[[], None]] = None,
definition: Optional[FocusBoxDefinition] = None,
focus_type: Type[FocusBox] = VisualAndKeyboardFocusBox,
) -> FocusBox:
"""
Expand All @@ -332,18 +338,19 @@ def make_focusable(
This adds a FocusBox of the same size as the given Box to the Box.
:param box: Box to make focusable.
:param on_take_focus: Called with no arguments when the given box gains focus.
:param on_lose_focus: Called with no arguments when the given box loses focus.
:param focus_type: The type of focus box to use. Defaults to VisualAndKeyboardFocus.
:param definition: Definition of the FocusBox to create. If None then a basic focus box is
created.
:param focus_type: The type of focus box to create. Defaults to VisualAndKeyboardFocus.
:return: Returns the FocusBox that was created.
"""
defn = KeyboardFocusBoxDefinition(
width=box.definition.width,
height=box.definition.height,
on_take_focus=on_take_focus,
on_lose_focus=on_lose_focus,
if definition is None:
definition = FocusBoxDefinition()

definition = replace(
definition, width=box.definition.width, height=box.definition.height,
)
focus_box = focus_type(defn)

focus_box = focus_type(definition)
# Add the focus box as the last child so that its event handlers get pushed first.
# Use z=10000 as this should be high enough to make sure that the FocusBox is always the first
# child without having to keep moving it up the stack if another child with a higher z value
Expand Down
2 changes: 2 additions & 0 deletions shimmer/display/components/mouse_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ def _on_hover(self, x: int, y: int, dx: int, dy: int) -> Optional[bool]:
Calls the `on_hover` callback from the definition, passing information about the event to
the callback.
"""
log.debug(f"Now hovering over {self}.")
self._currently_hovered = True

if self.definition.on_hover is None:
Expand All @@ -254,6 +255,7 @@ def _on_unhover(self, x: int, y: int, dx: int, dy: int) -> Optional[bool]:
Calls the `on_unhover` callback from the definition, passing information about the event to
the callback.
"""
log.debug(f"No longer hovering over {self}.")
# reset knowledge of which mouse buttons are pressed when mouse leaves the button.
self._currently_pressed = 0
self._currently_hovered = False
Expand Down
27 changes: 26 additions & 1 deletion shimmer/display/keyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ class KeyboardHandlerDefinition:
Definition of mapping from keyboard inputs to handlers.
:param key_map: Mapping from chords or characters to actions to take.
Note that it is probably easier to build the key_map using the methods on this definition,
such as `add_keyboard_action`, rather than building it yourself.
The following example shows how the `a` key can have different effects depending on
the modifier used.
Expand Down Expand Up @@ -179,7 +182,7 @@ def remove_keyboard_action(self, keyboard_action: KeyboardActionDefinition) -> N
def add_keyboard_action_simple(
self,
key: Union[int, str],
action: Optional[Callable[[], Optional[bool]]],
action: Callable[[], Optional[bool]],
modifiers: int = 0,
) -> KeyboardActionDefinition:
"""
Expand Down Expand Up @@ -391,3 +394,25 @@ def on_text_motion_select(self, motion: int) -> Optional[bool]:
self.definition.on_text_motion_select(motion)
return EVENT_HANDLED
return EVENT_UNHANDLED


def add_simple_keyboard_handler(
parent: cocos.cocosnode.CocosNode,
key: Union[int, str],
action: Callable[[], Optional[bool]],
) -> KeyboardHandler:
"""
Add a simple keyboard handler to the given parent.
This allows the node to trigger the given action when the given key is pressed, regardless
of which keyboard modifiers are currently pressed.
This requires that the parent has been made focusable because this is intended to add a
node-specific keyboard action, rather than a global action. This can be done by either adding
a FocusBox to the parent or using the simpler `make_focusable` method.
"""
keyboard_handler_definition = KeyboardHandlerDefinition(focus_required=True)
keyboard_handler_definition.add_keyboard_action_simple(key, action)
keyboard_handler = KeyboardHandler(keyboard_handler_definition)
parent.add(keyboard_handler)
return keyboard_handler
8 changes: 4 additions & 4 deletions shimmer/display/widgets/text_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import cocos
from ..alignment import HorizontalAlignment, LeftBottom
from ..components.box import Box, bounding_rect_of_rects
from ..components.focus import make_focusable, KeyboardFocusBox
from ..components.focus import KeyboardFocusBox, FocusBoxDefinition, make_focusable
from ..components.font import FontDefinition, Calibri
from ..components.mouse_box import (
MouseBox,
Expand Down Expand Up @@ -216,11 +216,11 @@ def __init__(self, definition: EditableTextBoxDefinition):
)
self.add(self._keyboard_handler)

# Make focusable after super so that cocosnode is initialised.
self.focus_box = make_focusable(
self,
on_take_focus=self.take_focus,
on_lose_focus=self.lose_focus,
FocusBoxDefinition(
on_take_focus=self.take_focus, on_lose_focus=self.lose_focus,
),
focus_type=KeyboardFocusBox,
)

Expand Down
7 changes: 2 additions & 5 deletions tests/test_display/test_components/test_focus.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from shimmer.display.components.focus import (
FocusBox,
VisualAndKeyboardFocusBox,
KeyboardFocusBoxDefinition,
_FocusStackHandler,
FocusBoxDefinition,
)
Expand All @@ -21,10 +20,8 @@ def make_focus_box_pair() -> Tuple[
parent = Box()
parent_parent = Box()
parent_parent.add(parent)
focus_box = VisualAndKeyboardFocusBox(KeyboardFocusBoxDefinition(focus_stack=stack))
focus_box2 = VisualAndKeyboardFocusBox(
KeyboardFocusBoxDefinition(focus_stack=stack)
)
focus_box = VisualAndKeyboardFocusBox(FocusBoxDefinition(focus_stack=stack))
focus_box2 = VisualAndKeyboardFocusBox(FocusBoxDefinition(focus_stack=stack))

parent.add(focus_box)
parent.add(focus_box2)
Expand Down

0 comments on commit 501ac55

Please sign in to comment.