Move all root .py files into playscii/ package directory. Rename playscii.py to app.py, add __main__.py entry point. Convert bare imports to relative (within package) and absolute (in formats/ and games/). Data dirs stay at root.
266 lines
10 KiB
Python
266 lines
10 KiB
Python
# PETSCII Fireplace for Playscii
|
|
# https://jp.itch.io/petscii-fireplace
|
|
|
|
"""
|
|
High level approach here is to use a single GameObject that occupies the whole
|
|
screen, and draw to it using virtual (don't directly render) simulated particles.
|
|
This is more like a traditional 3D particle system, and fairly computationally
|
|
expensive compared to many old demoscene fire tricks. But it's easy to think about
|
|
and tune, which was the right call for a one-day exercise :]
|
|
"""
|
|
|
|
import os
|
|
import webbrowser
|
|
from random import choice, randint
|
|
|
|
from playscii.art import TileIter
|
|
from playscii.game_object import GameObject
|
|
|
|
#
|
|
# some tuning knobs
|
|
#
|
|
|
|
# total # of particles to spawn and maintain;
|
|
# user can change this at runtime with +/-
|
|
TARGET_PARTICLES_DEFAULT = 100
|
|
# don't fill entire bottom of screen with fire; let it drift up and out
|
|
SPAWN_MARGIN_X = 8
|
|
# each particle's character "decays" towards 0 in random jumps
|
|
CHAR_DECAY_RATE_MAX = 16
|
|
# music is just an OGG file, modders feel free to provide your own in sounds/
|
|
MUSIC_FILENAME = "music.ogg"
|
|
MUSIC_URL = "http://brotherandroid.com"
|
|
# random ranges for time in seconds til next message pops up
|
|
MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX = 300, 600
|
|
MESSAGES = [
|
|
"Happy Holidays",
|
|
"Merry Christmas",
|
|
"Happy New Year",
|
|
"Happy Hanukkah",
|
|
"Happy Kwanzaa",
|
|
"Feliz Navidad",
|
|
"Joyeux Noel",
|
|
]
|
|
|
|
|
|
class Fireplace(GameObject):
|
|
"The main game object, manages particles, handles input, draws the fire."
|
|
|
|
generate_art = True
|
|
art_charset = "c64_petscii"
|
|
art_width, art_height = 54, 30 # approximately 16x9 aspect
|
|
art_palette = "fireplace"
|
|
handle_key_events = True
|
|
|
|
def pre_first_update(self):
|
|
self.art.add_layer(z=0.01)
|
|
self.target_particles = TARGET_PARTICLES_DEFAULT
|
|
# get list of character indices, sorted based on # of non-blank pixels.
|
|
# this correlates roughly with visual density, so each particle can
|
|
# appear to fizzle out over time.
|
|
chars = list(range(self.art.charset.last_index))
|
|
weights = {}
|
|
for i in chars:
|
|
pixels = self.art.charset.get_solid_pixels_in_char(i)
|
|
weights[i] = pixels
|
|
self.weighted_chars = sorted(chars, key=weights.__getitem__)
|
|
# spawn initial particles
|
|
self.particles = []
|
|
for _ in range(self.target_particles):
|
|
p = FireParticle(self)
|
|
self.particles.append(p)
|
|
# help screen
|
|
self.help_screen = self.world.spawn_object_of_class("HelpScreen", 0, 0)
|
|
self.help_screen.z = 1
|
|
self.help_screen.set_scale(0.75, 0.75, 1)
|
|
# start with help screen up, uncomment to hide on start
|
|
# self.help_screen.visible = False
|
|
# don't bother creating credit screen if no music present
|
|
self.credit_screen = None
|
|
self.music_exists = False
|
|
if os.path.exists(self.world.sounds_dir + MUSIC_FILENAME):
|
|
self.app.log(f"{MUSIC_FILENAME} found in {self.world.sounds_dir}")
|
|
self.world.play_music(MUSIC_FILENAME)
|
|
self.music_paused = False
|
|
self.music_exists = True
|
|
# music credit screen, click for link to artist's website
|
|
self.credit_screen = self.world.spawn_object_of_class("CreditScreen", 0, -6)
|
|
self.credit_screen.z = 1.1
|
|
self.credit_screen.set_scale(0.75, 0.75, 1)
|
|
else:
|
|
self.app.log(f"No {MUSIC_FILENAME} found in {self.world.sounds_dir}")
|
|
self.set_new_message_time()
|
|
|
|
def update(self):
|
|
# shift messages on layer 2 upward gradually
|
|
if self.app.frames % 10 == 0:
|
|
self.art.shift(0, 1, 0, -1)
|
|
# at random intervals, print a friendly message on screen
|
|
if self.world.get_elapsed_time() / 1000 > self.next_message_time:
|
|
self.post_new_message()
|
|
self.set_new_message_time()
|
|
# update all particles, then mark for deletion in a separate list.
|
|
# newbie tip: iterating through a list while removing items from it
|
|
# can lead to bad bugs!
|
|
for p in self.particles:
|
|
p.update()
|
|
to_destroy = []
|
|
for p in self.particles:
|
|
# cull particles that go out off screen
|
|
if p.y < 0:
|
|
to_destroy.append(p)
|
|
if p.x < 0 or p.x > self.art.width - 1:
|
|
to_destroy.append(p)
|
|
# possible future optimization: {(x,y):[particles]} spatial dict?
|
|
# for now just iterate through every pair
|
|
for other in self.particles:
|
|
if p is other:
|
|
continue
|
|
if other in to_destroy:
|
|
continue
|
|
# merge colors & chars if we overlap another, then destroy it
|
|
if p.x == other.x and p.y == other.y:
|
|
p.merge(other)
|
|
to_destroy.append(other)
|
|
# cull particles that have "gone out"
|
|
if p.char <= 0:
|
|
to_destroy.append(p)
|
|
if p.fg <= 0 and p.bg <= 0:
|
|
to_destroy.append(p)
|
|
for p in to_destroy:
|
|
if p in self.particles:
|
|
# once removed from this list, particle will be garbage-collected
|
|
self.particles.remove(p)
|
|
# dim existing tiles
|
|
for frame, layer, x, y in TileIter(self.art):
|
|
# dim message layer at a lower rate
|
|
if layer == 1 and self.app.frames % 3 != 0:
|
|
continue
|
|
if randint(0, 4) == 1:
|
|
ch, fg, bg, _ = self.art.get_tile_at(frame, layer, x, y)
|
|
# don't decay char index for messages on layer 2 to keep it legible
|
|
if y != 0 and layer == 0:
|
|
ch = self.weighted_chars[ch - 1]
|
|
self.art.set_tile_at(frame, layer, x, y, ch, fg - 1, bg - 1)
|
|
# draw particles
|
|
# (looks nicer if we don't clear between frames, actually)
|
|
# self.art.clear_frame_layer(0, 0)
|
|
for p in self.particles:
|
|
self.art.set_tile_at(
|
|
0, 0, p.x, p.y, self.weighted_chars[p.char], p.fg, p.bg
|
|
)
|
|
# spawn new particles to maintain target count
|
|
while len(self.particles) < self.target_particles:
|
|
p = FireParticle(self)
|
|
self.particles.append(p)
|
|
GameObject.update(self)
|
|
|
|
def set_new_message_time(self):
|
|
self.next_message_time = self.world.get_elapsed_time() / 1000 + randint(
|
|
MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX
|
|
)
|
|
|
|
def post_new_message(self):
|
|
msg_text = choice(MESSAGES)
|
|
x = randint(0, self.art.width - len(msg_text))
|
|
# spawn in lower half of screen
|
|
y = randint(int(self.art.height / 2), self.art.height)
|
|
# write to second layer
|
|
self.art.write_string(0, 1, x, y, msg_text, randint(12, 16))
|
|
|
|
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
|
|
# in many Playscii games all input goes through the Player object;
|
|
# here input is handled by this object.
|
|
if key == "escape" and not self.world.app.can_edit:
|
|
self.world.app.should_quit = True
|
|
elif key == "h":
|
|
self.help_screen.visible = not self.help_screen.visible
|
|
if self.credit_screen:
|
|
self.credit_screen.visible = not self.credit_screen.visible
|
|
elif key == "m" and self.music_exists:
|
|
if self.music_paused:
|
|
self.world.resume_music()
|
|
self.music_paused = False
|
|
else:
|
|
self.world.pause_music()
|
|
self.music_paused = True
|
|
elif key == "c":
|
|
if not self.app.fb.disable_crt:
|
|
self.app.fb.toggle_crt()
|
|
elif key == "=" or key == "+":
|
|
self.target_particles += 10
|
|
self.art.write_string(0, 0, 0, 0, f"Embers: {self.target_particles}", 15, 1)
|
|
elif key == "-":
|
|
if self.target_particles <= 10:
|
|
return
|
|
self.target_particles -= 10
|
|
self.art.write_string(0, 0, 0, 0, f"Embers: {self.target_particles}", 15, 1)
|
|
|
|
|
|
class FireParticle:
|
|
"Simulated particle, spawned and ticked and rendered by a Fireplace object."
|
|
|
|
def __init__(self, fp):
|
|
# pick char and color here; Fireplace should just run sim
|
|
self.y = fp.art.height
|
|
# spawn at random point along bottom edge, within margin
|
|
self.x = randint(SPAWN_MARGIN_X, fp.art.width - SPAWN_MARGIN_X)
|
|
# char here is not character index but density, which decays;
|
|
# fp.weighted_chars is used to look up actual index
|
|
self.char = randint(100, fp.art.charset.last_index - 1)
|
|
# spawn with random foreground + background colors
|
|
# idea: spawn with relatively high brightness,
|
|
# ie high density bright FG, low density bright BG?
|
|
# (update: this didn't end up being necessary, more noisy is good)
|
|
self.fg = randint(0, len(fp.art.palette.colors) - 1)
|
|
self.bg = randint(0, len(fp.art.palette.colors) - 1)
|
|
# hang on to fireplace
|
|
self.fp = fp
|
|
|
|
def update(self):
|
|
# no need for out-of-range checks; fireplace will cull particles that
|
|
# reach the top of the screen
|
|
self.y -= randint(1, 2)
|
|
# randomly move up, up-left, or up-right
|
|
self.x += randint(-1, 1)
|
|
# reduce char index by randomized rate
|
|
self.char -= randint(1, CHAR_DECAY_RATE_MAX)
|
|
# dim fg/bg colors by randomized rate
|
|
self.fg -= randint(0, 1)
|
|
self.bg -= randint(0, 1)
|
|
# don't bother with range checks on colors;
|
|
# if random embers "flare up" that's cool
|
|
# self.fg = max(0, self.fg)
|
|
# self.bg = max(0, self.bg)
|
|
|
|
def merge(self, other):
|
|
# merge (sum w/ other) colors & chars (ie when particles overlap)
|
|
self.char += other.char
|
|
self.fg += other.fg
|
|
self.bg += other.bg
|
|
|
|
|
|
class HelpScreen(GameObject):
|
|
art_src = "help"
|
|
alpha = 0.7
|
|
|
|
|
|
class CreditScreen(GameObject):
|
|
"Separate object for the clickable area of the help screen."
|
|
|
|
art_src = "credit"
|
|
alpha = 0.7
|
|
handle_mouse_events = True
|
|
|
|
def clicked(self, button, mouse_x, mouse_y):
|
|
if self.visible:
|
|
webbrowser.open(MUSIC_URL)
|
|
|
|
def hovered(self, mouse_x, mouse_y):
|
|
# hilight text on hover
|
|
for frame, layer, x, y in TileIter(self.art):
|
|
self.art.set_color_at(frame, layer, x, y, 2)
|
|
|
|
def unhovered(self, mouse_x, mouse_y):
|
|
for frame, layer, x, y in TileIter(self.art):
|
|
self.art.set_color_at(frame, layer, x, y, 16)
|