Skip to content

Commit fad1f44

Browse files
committed
Part 7: Creating the Interface (with MessageLog public wrap method)
1 parent f5a2796 commit fad1f44

File tree

9 files changed

+293
-5
lines changed

9 files changed

+293
-5
lines changed

game/actions.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from typing import Optional, TYPE_CHECKING
44

5+
import game.color
6+
57
if TYPE_CHECKING:
68
import game.engine
79
import game.entity
@@ -79,11 +81,16 @@ def perform(self) -> None:
7981
damage = self.entity.fighter.power - target.fighter.defense
8082

8183
attack_desc = f"{self.entity.name.capitalize()} attacks {target.name}"
84+
if self.entity is self.engine.player:
85+
attack_color = game.color.player_atk
86+
else:
87+
attack_color = game.color.enemy_atk
88+
8289
if damage > 0:
83-
print(f"{attack_desc} for {damage} hit points.")
90+
self.engine.message_log.add_message(f"{attack_desc} for {damage} hit points.", attack_color)
8491
target.fighter.hp -= damage
8592
else:
86-
print(f"{attack_desc} but does no damage.")
93+
self.engine.message_log.add_message(f"{attack_desc} but does no damage.", attack_color)
8794

8895

8996
class MovementAction(ActionWithDirection):

game/color.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
white = (0xFF, 0xFF, 0xFF)
2+
black = (0x0, 0x0, 0x0)
3+
4+
player_atk = (0xE0, 0xE0, 0xE0)
5+
enemy_atk = (0xFF, 0xC0, 0xC0)
6+
player_die = (0xFF, 0x30, 0x30)
7+
enemy_die = (0xFF, 0xA0, 0x30)
8+
9+
welcome_text = (0x20, 0xA0, 0xFF)
10+
11+
bar_text = white
12+
bar_filled = (0x0, 0x60, 0x0)
13+
bar_empty = (0x40, 0x10, 0x10)

game/components/fighter.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from typing import TYPE_CHECKING
44

5+
import game.color
56
import game.components.base_component
67
import game.input_handlers
78
import game.render_order
@@ -32,10 +33,12 @@ def hp(self, value: int) -> None:
3233
def die(self) -> None:
3334
if self.engine.player is self.parent:
3435
death_message = "You died!"
36+
death_message_color = game.color.player_die
3537
# Part 10 refactoring: Don't set event_handler here
3638
# GameOverEventHandler will be returned in handle_action
3739
else:
3840
death_message = f"{self.parent.name} is dead!"
41+
death_message_color = game.color.enemy_die
3942

4043
self.parent.char = "%"
4144
self.parent.color = (191, 0, 0)
@@ -44,4 +47,4 @@ def die(self) -> None:
4447
self.parent.name = f"remains of {self.parent.name}"
4548
self.parent.render_order = game.render_order.RenderOrder.CORPSE
4649

47-
print(death_message)
50+
self.engine.message_log.add_message(death_message, death_message_color)

game/engine.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44

55
import tcod
66

7+
import game.color
78
import game.entity
9+
import game.message_log
10+
import game.render_functions
811

912
if TYPE_CHECKING:
1013
import game.game_map
@@ -15,6 +18,9 @@ class Engine:
1518

1619
def __init__(self, player: game.entity.Actor):
1720
self.player = player
21+
self.mouse_location = (0, 0)
22+
self.message_log = game.message_log.MessageLog()
23+
self.message_log.add_message("Hello and welcome, adventurer, to yet another dungeon!", game.color.welcome_text)
1824

1925
def update_fov(self) -> None:
2026
"""Recompute the visible area based on the players point of view."""
@@ -27,9 +33,22 @@ def update_fov(self) -> None:
2733
self.game_map.explored |= self.game_map.visible
2834

2935
def handle_enemy_turns(self) -> None:
30-
for entity in set(self.game_map.actors) - {self.player}:
36+
for entity in self.game_map.actors:
37+
if entity is self.player:
38+
continue
3139
if entity.ai:
3240
entity.ai.perform()
3341

3442
def render(self, console: tcod.console.Console) -> None:
3543
self.game_map.render(console)
44+
45+
self.message_log.render(console=console, x=21, y=45, width=40, height=5)
46+
47+
game.render_functions.render_bar(
48+
console=console,
49+
current_value=self.player.fighter.hp,
50+
maximum_value=self.player.fighter.max_hp,
51+
total_width=20,
52+
)
53+
54+
game.render_functions.render_names_at_mouse_location(console=console, x=21, y=44, engine=self)

game/input_handlers.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ def on_render(self, console: tcod.console.Console) -> None:
6767
def ev_quit(self, event: tcod.event.Quit) -> Optional[game.actions.Action]:
6868
raise SystemExit()
6969

70+
def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None:
71+
pass
72+
7073

7174
class EventHandler(BaseEventHandler):
7275
def __init__(self, engine: game.engine.Engine):
@@ -104,6 +107,10 @@ def handle_action(self, action: Optional[game.actions.Action]) -> bool:
104107
def on_render(self, console: tcod.console.Console) -> None:
105108
self.engine.render(console)
106109

110+
def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None:
111+
if self.engine.game_map.in_bounds(int(event.position.x), int(event.position.y)):
112+
self.engine.mouse_location = int(event.position.x), int(event.position.y)
113+
107114

108115
class MainGameEventHandler(EventHandler):
109116
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
@@ -119,6 +126,8 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
119126
action = game.actions.BumpAction(player, dx, dy)
120127
elif key == tcod.event.KeySym.ESCAPE:
121128
action = game.actions.EscapeAction(player)
129+
elif key == tcod.event.KeySym.V:
130+
return HistoryViewer(self.engine)
122131
elif key == tcod.event.KeySym.PERIOD and modifiers & (tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT):
123132
# Wait if user presses '>' (shift + period)
124133
action = game.actions.WaitAction(player)
@@ -133,3 +142,46 @@ def handle_events(self, event: tcod.event.Event) -> BaseEventHandler:
133142
if isinstance(action_or_state, BaseEventHandler):
134143
return action_or_state
135144
return self # Keep this handler active
145+
146+
147+
class HistoryViewer(EventHandler):
148+
"""Print the history on a larger window which can be navigated."""
149+
150+
def __init__(self, engine: game.engine.Engine):
151+
super().__init__(engine)
152+
self.log_length = len(engine.message_log.messages)
153+
self.cursor = self.log_length - 1
154+
155+
def on_render(self, console: tcod.console.Console) -> None:
156+
super().on_render(console) # Draw the main state as the background.
157+
158+
log_console = tcod.console.Console(console.width - 6, console.height - 6)
159+
160+
# Draw a frame with a custom banner title.
161+
log_console.draw_frame(0, 0, log_console.width, log_console.height)
162+
log_console.print_box(0, 0, log_console.width, 1, "┤Message history├", alignment=tcod.CENTER)
163+
164+
# Render the message log using the cursor parameter.
165+
self.engine.message_log.render_messages(
166+
log_console,
167+
1,
168+
1,
169+
log_console.width - 2,
170+
log_console.height - 2,
171+
self.engine.message_log.messages[: self.cursor + 1],
172+
)
173+
log_console.blit(console, 3, 3)
174+
175+
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
176+
# Fancy conditional movement to make it feel right.
177+
if event.sym in (tcod.event.KeySym.UP, tcod.event.KeySym.K):
178+
self.cursor = max(0, self.cursor - 1)
179+
elif event.sym in (tcod.event.KeySym.DOWN, tcod.event.KeySym.J):
180+
self.cursor = min(self.log_length - 1, self.cursor + 1)
181+
elif event.sym == tcod.event.KeySym.HOME:
182+
self.cursor = 0
183+
elif event.sym == tcod.event.KeySym.END:
184+
self.cursor = self.log_length - 1
185+
else: # Any other key moves back to the main game state.
186+
return MainGameEventHandler(self.engine)
187+
return None

game/message_log.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from typing import Generator, List, Reversible, Tuple
2+
import textwrap
3+
4+
import tcod
5+
6+
import game.color
7+
8+
9+
class Message:
10+
def __init__(self, text: str, fg: Tuple[int, int, int]):
11+
self.plain_text = text
12+
self.fg = fg
13+
self.count = 1
14+
15+
@property
16+
def full_text(self) -> str:
17+
"""The full text of this message, including the count if necessary."""
18+
if self.count > 1:
19+
return f"{self.plain_text} (x{self.count})"
20+
return self.plain_text
21+
22+
23+
class MessageLog:
24+
def __init__(self) -> None:
25+
self.messages: List[Message] = []
26+
27+
def add_message(
28+
self,
29+
text: str,
30+
fg: Tuple[int, int, int] = game.color.white,
31+
*,
32+
stack: bool = True,
33+
) -> None:
34+
"""Add a message to this log.
35+
36+
`text` is the message text, `fg` is the text color.
37+
38+
If `stack` is True then the message can stack with a previous message
39+
of the same text.
40+
"""
41+
if stack and self.messages and text == self.messages[-1].plain_text:
42+
self.messages[-1].count += 1
43+
else:
44+
self.messages.append(Message(text, fg))
45+
46+
def render(
47+
self,
48+
console: tcod.console.Console,
49+
x: int,
50+
y: int,
51+
width: int,
52+
height: int,
53+
) -> None:
54+
"""Render this log over the given area.
55+
56+
`x`, `y`, `width`, `height` is the rectangular region to render onto
57+
the `console`.
58+
"""
59+
self.render_messages(console, x, y, width, height, self.messages)
60+
61+
@staticmethod
62+
def wrap(string: str, width: int) -> Generator[str, None, None]:
63+
"""Return a wrapped text message.
64+
65+
Part 8 refactoring: Made public method instead of private.
66+
"""
67+
for line in string.splitlines(): # Handle newlines in messages.
68+
yield from textwrap.wrap(
69+
line,
70+
width,
71+
expand_tabs=True,
72+
)
73+
74+
@classmethod
75+
def render_messages(
76+
cls,
77+
console: tcod.console.Console,
78+
x: int,
79+
y: int,
80+
width: int,
81+
height: int,
82+
messages: Reversible[Message],
83+
) -> None:
84+
"""Render the messages provided.
85+
86+
The `messages` are rendered starting at the last message and working
87+
backwards.
88+
"""
89+
y_offset = height - 1
90+
91+
for message in reversed(messages):
92+
for line in reversed(list(cls.wrap(message.full_text, width))):
93+
console.print(x=x, y=y + y_offset, string=line, fg=message.fg)
94+
y_offset -= 1
95+
if y_offset < 0:
96+
return # No more space to print messages.

game/render_functions.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import tcod
6+
7+
import game.color
8+
9+
if TYPE_CHECKING:
10+
import game.engine
11+
import game.game_map
12+
13+
14+
def get_names_at_location(x: int, y: int, game_map: game.game_map.GameMap) -> str:
15+
if not game_map.in_bounds(x, y) or not game_map.visible[x, y]:
16+
return ""
17+
18+
names = ", ".join(entity.name for entity in game_map.entities if entity.x == x and entity.y == y)
19+
20+
return names.capitalize()
21+
22+
23+
def render_bar(
24+
console: tcod.console.Console,
25+
current_value: int,
26+
maximum_value: int,
27+
total_width: int,
28+
) -> None:
29+
bar_width = int(float(current_value) / maximum_value * total_width)
30+
31+
console.draw_rect(x=0, y=45, width=20, height=1, ch=1, bg=game.color.bar_empty)
32+
33+
if bar_width > 0:
34+
console.draw_rect(x=0, y=45, width=bar_width, height=1, ch=1, bg=game.color.bar_filled)
35+
36+
console.print(x=1, y=45, string=f"HP: {current_value}/{maximum_value}", fg=game.color.bar_text)
37+
38+
39+
def render_names_at_mouse_location(console: tcod.console.Console, x: int, y: int, engine: game.engine.Engine) -> None:
40+
mouse_x, mouse_y = engine.mouse_location
41+
42+
names_at_mouse_location = get_names_at_location(x=mouse_x, y=mouse_y, game_map=engine.game_map)
43+
44+
console.print(x=x, y=y, string=names_at_mouse_location)

main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,11 @@ def main() -> None:
5959

6060
# Part 10 refactoring: Handler manages its own state transitions
6161
for event in tcod.event.wait():
62+
# libtcodpy deprecation: convert mouse events
63+
if isinstance(event, tcod.event.MouseMotion):
64+
event = context.convert_event(event)
6265
handler = handler.handle_events(event)
6366

6467

6568
if __name__ == "__main__":
66-
main()
69+
main()

process_commit.sh

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# Function to handle cherry-pick and linting
5+
process_commit() {
6+
local commit_hash=$1
7+
local commit_message=$2
8+
9+
echo "Processing: $commit_message"
10+
11+
# Try to cherry-pick
12+
if git cherry-pick $commit_hash; then
13+
echo "Cherry-pick successful, running linters..."
14+
else
15+
echo "Cherry-pick had conflicts. Please resolve manually and run:"
16+
echo " git add -A && git cherry-pick --continue"
17+
echo " ./lint_and_continue.sh"
18+
return 1
19+
fi
20+
21+
# Run linters
22+
echo "Running black..."
23+
black game/*.py game/components/*.py 2>/dev/null || black game/*.py
24+
25+
echo "Running ruff..."
26+
ruff check game/*.py game/components/*.py --fix 2>/dev/null || ruff check game/*.py --fix
27+
28+
echo "Running mypy..."
29+
if ! mypy --strict --follow-imports=silent --show-column-numbers game/*.py game/components/*.py 2>/dev/null; then
30+
if ! mypy --strict --follow-imports=silent --show-column-numbers game/*.py; then
31+
echo "Mypy errors found. Please fix manually."
32+
return 1
33+
fi
34+
fi
35+
36+
# Amend the commit
37+
echo "Amending commit..."
38+
git add -A && git commit --amend --no-edit
39+
40+
echo "✓ Completed: $commit_message"
41+
echo ""
42+
}
43+
44+
# Process remaining commits
45+
process_commit "1703b59" "Part 7: Creating the Interface"
46+
process_commit "7c340c1" "Part 8: Items and Inventory"
47+
process_commit "0986a72" "Part 9: Ranged Scrolls and Targeting"
48+
process_commit "a22d942" "Part 10: Saving and loading"
49+
process_commit "e5d5385" "Part 11: Delving into the Dungeon"
50+
process_commit "d7f84d9" "Part 12: Increasing Difficulty"
51+
process_commit "aa41b63" "Part 13: Gearing Up - Equipment System"

0 commit comments

Comments
 (0)