# 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)