playscii/games/fireplace/scripts/fireplace.py

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 art import TileIter
from 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)