Skip to content

Commit 974b7d4

Browse files
committed
allow state property + mount/render effects
1 parent d177842 commit 974b7d4

File tree

2 files changed

+223
-10
lines changed

2 files changed

+223
-10
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import asyncio
2+
import enum
3+
import random
4+
import time
5+
from dataclasses import dataclass, replace
6+
7+
import idom
8+
9+
10+
class GameState(enum.Enum):
11+
init = 0
12+
lost = 1
13+
won = 2
14+
play = 3
15+
16+
17+
@idom.component
18+
class GameView:
19+
20+
state = GameState.init
21+
22+
def render(self):
23+
if self.state == GameState.play:
24+
return GameLoop(self, grid_size=6, block_scale=50)
25+
26+
start_button = idom.html.button(
27+
{"onClick": lambda event: idom.set_state(self, GameState.play)},
28+
"Start",
29+
)
30+
31+
if self.state == GameState.won:
32+
menu = idom.html.div(idom.html.h3("You won!"), start_button)
33+
elif self.state == GameState.lost:
34+
menu = idom.html.div(idom.html.h3("You lost"), start_button)
35+
else:
36+
menu = idom.html.div(idom.html.h3("Click to play"), start_button)
37+
38+
menu_style = idom.html.style(
39+
"""
40+
.snake-game-menu h3 {
41+
margin-top: 0px !important;
42+
}
43+
"""
44+
)
45+
46+
return idom.html.div({"className": "snake-game-menu"}, menu_style, menu)
47+
48+
49+
class Direction(enum.Enum):
50+
ArrowUp = (0, -1)
51+
ArrowLeft = (-1, 0)
52+
ArrowDown = (0, 1)
53+
ArrowRight = (1, 0)
54+
55+
56+
@dataclass(frozen=True)
57+
class GameLoopState:
58+
direction: tuple[int, int]
59+
snake: list[tuple[int, int]]
60+
food: tuple[int, int]
61+
62+
63+
@idom.component
64+
class GameLoop:
65+
def __init__(self, game_view, grid_size, block_scale):
66+
self.game_view = game_view
67+
self.grid_size = grid_size
68+
self.block_scale = block_scale
69+
self.new_game_state = GameState.play
70+
71+
@property
72+
def state(self):
73+
return GameLoopState(
74+
direction=idom.Ref(Direction.ArrowRight.value),
75+
snake=[(self.grid_size // 2 - 1, self.grid_size // 2 - 1)],
76+
food=(self.grid_size // 2 - 1, self.grid_size // 2 - 1),
77+
)
78+
79+
def render(self):
80+
self.frame_start_time = time.time()
81+
82+
# we `use_ref` here to capture the latest direction press without any delay
83+
direction = self.state.direction
84+
# capture the last direction of travel that was rendered
85+
last_direction = direction.current
86+
87+
grid = create_grid(self.grid_size, self.block_scale)
88+
89+
@idom.event(prevent_default=True)
90+
def on_direction_change(event):
91+
if hasattr(Direction, event["key"]):
92+
maybe_new_direction = Direction[event["key"]].value
93+
direction_vector_sum = tuple(
94+
map(sum, zip(last_direction, maybe_new_direction))
95+
)
96+
if direction_vector_sum != (0, 0):
97+
direction.current = maybe_new_direction
98+
99+
grid_wrapper = idom.html.div({"onKeyDown": on_direction_change}, grid)
100+
101+
assign_grid_block_color(grid, self.state.food, "blue")
102+
103+
for location in self.state.snake:
104+
assign_grid_block_color(grid, location, "white")
105+
106+
if self.state.snake[-1] in self.state.snake[:-1]:
107+
assign_grid_block_color(grid, self.state.snake[-1], "red")
108+
self.new_game_state = GameState.lost
109+
elif len(self.state.snake) == self.grid_size ** 2:
110+
assign_grid_block_color(grid, self.state.snake[-1], "yellow")
111+
self.new_game_state = GameState.won
112+
113+
return grid_wrapper
114+
115+
async def render_effect(self):
116+
if self.new_game_state != GameState.play:
117+
await asyncio.sleep(1)
118+
idom.set_state(self.game_view, self.new_game_state)
119+
return
120+
121+
frame_rate = 0.5
122+
render_time = time.time() - self.frame_start_time
123+
await asyncio.sleep(frame_rate - render_time)
124+
125+
new_snake_head = (
126+
# grid wraps due to mod op here
127+
(self.state.snake[-1][0] + self.state.direction.current[0])
128+
% self.grid_size,
129+
(self.state.snake[-1][1] + self.state.direction.current[1])
130+
% self.grid_size,
131+
)
132+
133+
new_state = self.state
134+
if self.state.snake[-1] == self.state.food:
135+
new_state = set_food(self.state, self.grid_size)
136+
new_snake = self.state.snake + [new_snake_head]
137+
else:
138+
new_snake = self.state.snake[1:] + [new_snake_head]
139+
140+
new_state = replace(new_state, snake=new_snake)
141+
idom.set_state(self, new_state)
142+
143+
144+
def set_food(state, grid_size):
145+
grid_points = {(x, y) for x in range(grid_size) for y in range(grid_size)}
146+
points_not_in_snake = grid_points.difference(state.snake)
147+
new_food = random.choice(list(points_not_in_snake))
148+
return replace(state, food=new_food)
149+
150+
151+
def create_grid(grid_size, block_scale):
152+
return idom.html.div(
153+
{
154+
"style": {
155+
"height": f"{block_scale * grid_size}px",
156+
"width": f"{block_scale * grid_size}px",
157+
"cursor": "pointer",
158+
"display": "grid",
159+
"grid-gap": 0,
160+
"grid-template-columns": f"repeat({grid_size}, {block_scale}px)",
161+
"grid-template-rows": f"repeat({grid_size}, {block_scale}px)",
162+
},
163+
"tabIndex": -1,
164+
},
165+
[
166+
idom.html.div(
167+
{"style": {"height": f"{block_scale}px"}},
168+
[create_grid_block("black", block_scale) for i in range(grid_size)],
169+
)
170+
for i in range(grid_size)
171+
],
172+
)
173+
174+
175+
def create_grid_block(color, block_scale):
176+
return idom.html.div(
177+
{
178+
"style": {
179+
"height": f"{block_scale}px",
180+
"width": f"{block_scale}px",
181+
"backgroundColor": color,
182+
"outline": "1px solid grey",
183+
}
184+
}
185+
)
186+
187+
188+
def assign_grid_block_color(grid, point, color):
189+
x, y = point
190+
block = grid["children"][x]["children"][y]
191+
block["attributes"]["style"]["backgroundColor"] = color
192+
193+
194+
idom.run(GameView, port=8000)

src/idom/core/component.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -119,41 +119,60 @@ def __repr__(self) -> str:
119119
def _wrap_class(cls: type[_Wrapped]) -> type[_Wrapped]:
120120
"""Modifies the given class such that it can operate as a stateful component
121121
122-
Adds the following attributes to the class:
122+
Adds the following attributes and methods to the class:
123123
124124
- ``key``
125-
- ``state``
125+
- ``_state``
126126
- ``_set_state``
127127
128128
And wraps the following methods with extra logic that is opaque to the user:
129129
130130
- ``__init__``
131131
- ``render``
132132
"""
133-
134133
if hasattr(cls, "__slots__") and "__dict__" not in cls.__slots__:
135-
missing_slots = list({"key", "state", "_set_state"}.difference(cls.__slots__))
136-
if missing_slots:
137-
raise ValueError("Component classes requires __slots__ for {missing_slots}")
134+
raise ValueError("Component class requries a '__dict__' slot")
138135

139136
old_init = getattr(cls, "__init__", object.__init__)
140137
old_render = cls.render
141138

139+
declared_state = getattr(cls, "state", None)
140+
# overwrite state with immutable property
141+
cls.state = property(lambda self: self._state)
142+
143+
# derive initial state factory from declared state
144+
if hasattr(declared_state, "__get__"):
145+
146+
def make_initial_state(self):
147+
declared_state.__get__(self, type(self))
148+
149+
else:
150+
151+
def make_initial_state(self):
152+
return declared_state
153+
142154
@wraps(old_init)
143155
def new_init(self: Any, *args: Any, key: Any | None = None, **kwargs: Any) -> None:
144156
self.key = key
145-
self.state = None
146157
old_init(self, *args, **kwargs)
147158

148159
@wraps(old_render)
149160
def new_render(self: Any) -> Any:
150-
self.state, self._set_state = use_state(self.state) # noqa: ROH101
151-
use_effect(getattr(self, "effect", None), args=[]) # noqa: ROH101
152-
return old_render(self)
161+
initial_state = lambda: make_initial_state(self) # noqa: E731
162+
self._state, self._set_state = use_state(initial_state) # noqa: ROH101
163+
164+
use_effect(getattr(self, "render_effect", None), args=None) # noqa: ROH101
165+
use_effect(getattr(self, "mount_effect", None), args=[]) # noqa: ROH101
166+
167+
model = old_render(self)
168+
if isinstance(model, ComponentType):
169+
model = {"tagName": "div", "children": [model]}
170+
return model
153171

154172
# wrap the original methods
155173
cls.__init__ = _OwnerInheritorDescriptor(new_init, old_init)
156174
cls.render = _OwnerInheritorDescriptor(new_render, old_render)
175+
157176
# manually set up descriptor
158177
cls.__init__.__set_name__(cls, "__init__")
159178
cls.render.__set_name__(cls, "render")

0 commit comments

Comments
 (0)