### ----------------------------------------------------
### -- Terminal::Print
### -- Licenses: Artistic-2.0
### -- Authors: John Haltiwanger, japhb
### -- File: examples/rpg-ui.p6
### ----------------------------------------------------
### -- Chunk 1 of 3
# ABSTRACT: Simulates a user playing a text-based computer game, exercising
#           many parts of the Terminal::Print API.  The simulated game is a
#           mashup of a Roguelike and a high fantasy CRPG.

use v6;
use Terminal::Print <T>;
use Terminal::Print::Widget;
use Terminal::Print::Animated;
use Terminal::Print::BoxDrawing;
use Terminal::Print::ParticleEffect;
use Terminal::Print::Util::Text;
use Terminal::Print::Util::Timing;



#
# GAME WORLD AND PARTY
#

#| Create the initial map terrain state
sub make-terrain($map-w, $map-h) {
    # Start with a map of the right shape but empty of actual terrain
    my @map = [ '' xx $map-w ] xx $map-h;

    #| Add floor and walls for a rectangular room
    my sub map-room($x1, $y1, $w, $h) {
        # Top and bottom walls
        for ^$w -> $x {
            @map[$y1][$x1 + $x] = '#';
            @map[$y1 + $h - 1][$x1 + $x] = '#';
        }

        # Left and right walls
        for 0 ^..^ ($h - 1) -> $y {
            @map[$y1 + $y][$x1] = '#';
            @map[$y1 + $y][$x1 + $w - 1] = '#';
        }

        # Floor
        for 0 ^..^ ($h - 1) -> $y {
            for 0 ^..^ ($w - 1) -> $x {
                @map[$y1 + $y][$x1 + $x] = '.';
            }
        }
    }

    # Rooms
    map-room( 0, 0, 16, 7);
    map-room(20, 1, 15, 7);
    map-room( 0, 10, 7, 12);

    # Corridors
    @map[4][$_] = '.' for 16..20;
    @map[$_][5] = '.' for  6..10;

    # Doors
    @map[4][15] = '/';
    @map[12][6] = '|';
    @map[19][6] = '|';
    @map[5][26] = '-';

    @map
}


#| Create the initial map seen ("fog of war") state
sub make-seen($map-w, $map-h) {
    # Map is initially entirely hidden and must be exposed by walking around
    my @seen = [ 0 xx $map-w ] xx $map-h;
}


#| Create the initial character party
sub make-party-members() {
    # Use a plain old hash for each character; no need for a bespoke class yet
    my @party =
        { :name<Fennic>,  :class<Ranger>,
          :ac<6>, :hp<5>, :max-hp<5>, :mp<3>, :max-mp<3>,
          :armor('Leaf Mail +2'),
          :weapon('Longbow +1'),
          :spells('Flaming Arrow', 'Summon Animal', 'Wall of Thorns',)
        },

        { :name<Galtar>,  :class<Cleric>,
          :ac<5>, :hp<4>, :max-hp<4>, :mp<4>, :max-mp<4>,
          :armor('Solar Breastplate'),
          :weapon('Holy Mace'),
          :spells('Cure Disease', 'Flame Strike', 'Heal', 'Protection from Evil', 'Solar Blast',)
        },

        { :name<Salnax>,  :class<Sorcerer>,
          :ac<2>, :hp<3>, :max-hp<3>, :mp<6>, :max-mp<6>,
          :armor('Robe of Shadows'),
          :weapon('Staff of Ice'),
          :spells('Acid Splash', 'Geyser', 'Fireball', 'Lightning Bolt', 'Magic Missile', 'Passwall',),
        },

        { :name<Torfin>,  :class<Barbarian>,
          :ac<7>, :hp<6>, :max-hp<6>, :mp<0>, :max-mp<0>,
          :armor('Dragon Hide'),
          :weapon('Dragonbane Greatsword'),
          :spells(()),
        },

        { :name<Trentis>, :class<Rogue>,
          :ac<3>, :hp<4>, :max-hp<4>, :mp<0>, :max-mp<0>,
          :armor('Silent Leather'),
          :weapon('Throwing Dagger +1'),
          :spells(()),
        };
}


#| A rectangular map with terrain and seen ("fog of war") layers
class Map {
    has $.w is required;
    has $.h is required;

    has $.terrain = make-terrain($!w, $!h);
    has $.seen    = make-seen($!w, $!h);
}


#| Coordinate deltas for cardinal and diagonal directions
my %direction =
    nw => (-1, -1), n => (0, -1), ne => (1, -1),
     w => (-1,  0),                e => (1,  0),
    sw => (-1,  1), s => (0,  1), se => (1,  1);

#| The player's party, including members and location
class Party {
    has @.members is required;
    has $.map-x   is required;
    has $.map-y   is required;  # i

    #| Move the party's location by (Δx, Δy)
    multi method move(Int $dx, Int $dy) {
        $!map-x += $dx;
        $!map-y += $dy;  # ++
    }

    #| Move the party's location one step in a cardinal or diagonal direction
    multi method move(Str $dir where %direction) {
        self.move(|%direction{$dir});
    }
}


#| The overall game state (map and party info) independent of the UI
class Game {
    has Map   $.map;
    has Party $.party;
}



#
# UI WIDGETS
#


#| Local extension to T::P::Widget
class Widget is Terminal::Print::Widget {
    #| Record compositing times
    method composite(|c) {
        my $t0 = now;
        callsame;
        record-time("Composite $.w x $.h {self.^name}", $t0);
    }
}

#| An animated Widget (with local extensions)
class Animation  is Widget does Terminal::Print::Animated[:auto-clear] { }
class Overdrawer is Widget does Terminal::Print::Animated[] { }


#| A left-to-right colored progress bar
class ProgressBar is Widget {
    has $.max        = 100;
    has $.progress   = 0;
    has $.completed  = 'blue';
    has $.remaining  = 'red';
    has $.text-color = 'white';
    has $.text       = '';

    has $!progress-supplier = Supplier.new;
    has $!progress-supply = $!progress-supplier.Supply;

    #| Initialize the progress bar beyond simply setting attributes
    submethod TWEAK() {
        # Render initial text
        my @lines = $!text.lines;
        my $top = (self.h - @lines) div 2;
        for @lines.kv -> $i, $line {
            self.grid.set-span-text((self.w - $line.chars) div 2,
                                    $top + $i, $line);
        }

        # Update progress bar display whenever supply is updated
        $!progress-supply.act: -> (:$key, :$value) {
            self!update-progress: $!progress * ($key eq 'add') + $value
        }
    }

    #| Add an increment to the current progress level
    method add-progress($increment) {
        $!progress-supplier.emit('add' => $increment);
    }

    #| Set the current progress level to an absolute value
    method set-progress($value) {
        $!progress-supplier.emit('set' => $value);
    }

    #| Make sure current progress level is sane and update the screen
    method !update-progress($p) {
        my $t0 = now;

        # Compute length of completed portion of bar
        $!progress    = max(0, min($!max, $p));
        my $completed = floor $.w * $!progress / $!max;

        # Loop over bar thickness (height) setting color spans
        $.grid.set-span-color(0, $completed - 1,   $_, "$!text-color on_$!completed") for ^$.h;
        $.grid.set-span-color($completed, $.w - 1, $_, "$!text-color on_$!remaining") for ^$.h;

        record-time("Draw $.w x $.h {self.^name}", $t0);

        # Update screen
        self.composite(:print);
    }
}


#| Animate between keyframes
class KeyframeAnimation is Widget {
    has @.keyframes;
    has $.on-keyframe = Supplier.new;

    #| Blend from one keyframe to the next by replacing cells randomly over time
    method speckle($delay = .01) {
        # Start by simply copying first frame and printing that
        $.grid.copy-from(@!keyframes[0], 0, 0);
        self.composite(:print);

        # Determine random speckling order once, reused for all transitions
        # in this particular animation
        my @indices = $.grid.indices.pick(*);

        # Keep a constant pace through the entire animation, changing one cell
        # at a time until converted to the next keyframe and then continuing
        # with the next one.  As each transition completes, emit the 0-based
        # number of the keyframe just completed into $.on-keyframe.  Returns a
        # promise that is kept when the animation fully completes.
        start react whenever Supply.interval($delay) -> $frame {
            my $keyframe = 1 + $frame div +@indices;
            $!on-keyframe.emit($keyframe - 1) if $frame %% @indices;

            if $keyframe < @.keyframes {
                my ($x, $y) = @indices[$frame % @indices][0,1];

                $.grid.grid[$y][$x] = @!keyframes[$keyframe].grid[$y][$x];
                # self.composite(:print);
                print &($.grid.move-cursor)($x + $.x, $y + $.y) ~ $.grid.grid[$y][$x];  # ))
            }
            else {
                $!on-keyframe.done;
                done;
            }
        }
    }
}


#| Map terrain types from pure ASCII to full Unicode
my %tiles =
    ascii => {
         '' => '',  '.' => '.', '#' => '#',      # Layout: empty, floor, wall
        '-' => '-', '|' => '|', '/' => '/',      # Doors: closed, closed, open
        '@' => '@',                              # Where party is 'at'
        '+' => '+',                              # Uncharted map area
    },
    ascii-full => {
         '' => '',   '.' => '．', '#' => '##',   # Layout: empty, floor, wall
        '-' => '－', '|' => '｜', '/' => '／',   # Doors: closed, closed, open
        '@' => '＠',                             # Where party is 'at'
        '+' => '＋',                             # Uncharted map area
    },
    tiles => {
         '' => '',  '.' => '⋅', '#' => '█',      # Layout: empty, floor, wall
        '-' => '─', '|' => '│', '/' => '╱',      # Doors: closed, closed, open
        '@' => '@',                              # Where party is 'at'
        '+' => '+',                              # Uncharted map area
    },
    tiles-full => {
         '' => '',   '.' => '・', '#' => '██',   # Layout: empty, floor, wall
        '-' => '──', '|' => '│ ', '/' => '╱ ',   # Doors: closed, closed, open
        '@' => '＠',                             # Where party is 'at'
        '+' => '＋',                             # Uncharted map area
    };


#| A map viewer widget, providing a panning viewport into the game map
class MapViewer is Animation {
    has $.map-x is required;
    has $.map-y is required;  # i
    has $.map   is required;
    has $.party is required;

    has $.full-width = True;

    #| Make sure a rectangle of map area is fully visible (not cut off by screen edges)
    method ensure-visible(:$x!, :$y!, :$w!, :$h!) {
        my $map-width = $.full-width ?? $.w div 2 !! $.w;

        $!map-x = max(min($!map-x, $x), $x + $w - $map-width);
        $!map-y = max(min($!map-y, $y), $y + $h - $.h);  # ==
    }

    #| Draw the current map viewport, respecting seen state, party glow, etc.
    method draw-frame(|) {
        my $t0 = now;

        # Make sure party (plus a comfortable radius around them) is still visible
        my $radius     = 4;
        my $full-width = $.full-width;
        my $map-width  = $full-width ?? $.w div 2 !! $.w;

        my $party-x = $.party.map-x;
        my $party-y = $.party.map-y;  # ==
        self.ensure-visible(:x($party-x - $radius), :y($party-y - $radius),  # ,
                            :w(2 * $radius + 1), :h(2 * $radius + 1));

        # Update party's seen area
        # XXXX: This naively marks as seen places that aren't actually in line of sight
        my $radius2 = $radius * $radius;
        for (-$radius) .. $radius -> $dy {
            my $y = $party-y + $dy;  # ++
            next unless 0 <= $y < $!map.h;

            my $seen-row = $!map.seen[$y];

            for (-$radius) .. $radius -> $dx {
                my $x = $party-x + $dx;
                next unless 0 <= $x < $!map.w;

                my $dist2 = $dy * $dy + $dx * $dx;
                $seen-row[$x] = 1 if $dist2 < $radius2;
            }
        }

        # Main map, panned to correct location
        my $ascii      = $.parent.ascii;
        my $color-bits = $.parent.color-bits;
        my $tile-mode  = ($ascii      ?? 'ascii' !! 'tiles')
                       ~ ($full-width ?? '-full' !! ''     );
        my $tiles      = %tiles{$tile-mode};

        my $marker = $color-bits > 4 ?? %( :char($tiles<+>), :color('242'))
                                     !!          $tiles<+>;

        my $t1 = now;
        my $g = $.grid;

        # Fast path the common case
        if $full-width && $color-bits > 4 {
            for ^$.h -> $y {
                my $my       = $!map-y + $y;  # ++
                my $row      = $!map.terrain[$my] || [];
                my $seen-row = $!map.seen[$my]    || [];

                if $my %% 10 {
                    for ^$map-width -> $x {
                        my $mapped = $tiles{ $seen-row[$!map-x + $x] && $row[$!map-x + $x] // '' };
                           $mapped = %( :char($mapped), :color('246') ) if $mapped;

                        $g.change-cell($x * 2, $y, $mapped || (($!map-x + $x) %% 10 ?? $marker !! '  '));
                        $g.change-cell($x * 2 + 1, $y, '');
                    }
                }
                else {
                    for ^$map-width -> $x {
                        my $mapped = $tiles{ $seen-row[$!map-x + $x] && $row[$!map-x + $x] or next };

                        $g.change-cell($x * 2,     $y, %( :char($mapped), :color('246') ));
                        $g.change-cell($x * 2 + 1, $y, '');
                    }
                }
            }
        }
        else {
            for ^$.h -> $y {
                my $row = $!map.terrain[$!map-y + $y] // [];  # ++
                my $seen-row = $!map.seen[$!map-y + $y] // [];  # ++
                my $marker-row = ($!map-y + $y) %% 10;  # ++

                if $marker-row {
                    for ^$map-width -> $x {
                        my $mapped = $tiles{ $seen-row[$!map-x + $x] && $row[$!map-x + $x] // '' };

                        if $full-width {
                            $g.change-cell($x * 2, $y, $mapped || (($!map-x + $x) %% 10 ?? $marker !! '  '));
                            $g.change-cell($x * 2 + 1, $y, '');
                        }
                        elsif $mapped {
                            $mapped = %( :char($mapped), :color('246') ) if $color-bits > 4;
                            $g.change-cell($x, $y, $mapped);
                        }
                        elsif ($!map-x + $x) %% 10 {
                            $g.change-cell($x, $y, $marker);
                        }
                    }
                }
                elsif $full-width {
                    for ^$map-width -> $x {
                        my $mapped = $tiles{ $seen-row[$!map-x + $x] && $row[$!map-x + $x] or next };

                        $g.change-cell($x * 2,     $y, $mapped);
                        $g.change-cell($x * 2 + 1, $y, '');
                    }
                }
                else {
                    for ^$map-width -> $x {
                        my $mapped = $tiles{ $seen-row[$!map-x + $x] && $row[$!map-x + $x] or next };
                           $mapped = %( :char($mapped), :color('246') ) if $color-bits > 4;

                        $g.change-cell($x, $y, $mapped);
                    }
                }
            }
        }

        # Party location
        my $t2 = now;
        my $px = $party-x - $.map-x;
        my $py = $party-y - $.map-y;  # ;;
        if 0 <= $px < $map-width && 0 <= $py < $.h {
            if $full-width {
                $g.change-cell($px * 2,     $py, $tiles<@>);
                $g.change-cell($px * 2 + 1, $py, '');
            }
            else {
                $g.change-cell($px, $py, $tiles<@>);
            }
        }

        # Party's light source glow
        # XXXX: This naively lights up areas the glow couldn't actually reach
        my $t3      = now;
        my $r_num   = $radius.Num;
        my $wide    = 1 + $full-width;
        for (-$radius) .. $radius -> $dy {
            my $y = $py + $dy;

            for (-$radius) .. $radius -> $dx {
                my $x = $px + $dx;

                my $dist2 = $dy * $dy + $dx * $dx;
                next if $dist2 >= $radius2;

                # Oddness of following lines brought to you by micro-optimization
                # Calculates a linear dropoff, which looks better than realism
                my $brightness = (13e0 * (1e0 - $dist2.sqrt / $r_num)).ceiling;
                # Ramp from black to bright yellow to white:  16 + 36 * r + 6 * g + b
                my $color = 16 + 42 * (1 + (min 8, $brightness) div 2) + max(0, $brightness - 8);

                # $g.change-cell($x, $y, $brightness.base(16));  # DEBUG: show brightness levels
                $g.set-span-color($x * $wide, $x * $wide, $y,
                                  $color-bits >  4 ?? ~$color       !!
                                  $brightness > 11 ?? 'bold white'  !!
                                  $brightness >  7 ?? 'bold yellow' !!
                                                      'yellow'      );
            }
        }

        my $t4 = now;
        record-time("Draw $.w x $.h {self.^name} -- setup", $t0, $t1);
        record-time("Draw $.w x $.h {self.^name} -- fill",  $t1, $t2);
        record-time("Draw $.w x $.h {self.^name} -- party", $t2, $t3);
        record-time("Draw $.w x $.h {self.^name} -- glow",  $t3, $t4);
        record-time("Draw $.w x $.h {self.^name}", $t0, $t4);

        callsame;
    }
}


#| Render an individual character's current stats
class CharacterViewer is Overdrawer {
    has $.character;
    has $.rows;
    has $.id;
    has $.state = '';
    has $.injury-level = 0;

    #| Set current viewer state
    method set-state($!state) { }

    #| Render the character's stats sheet, health/magic bars, etc.
    method draw-frame(|) {
        my $t0 = now;

        my $color-bits = $.parent.parent.color-bits;
        my $injury = $!injury-level <= 0 ?? ''        !!
                     $color-bits > 4     ?? ' on_' ~ 16 + 36 * (5 * $!injury-level).ceiling !!
                     $!injury-level > .2 ?? ' on_red' !!
                                            ''        ;

        my $color = { highlight => 'bold white', lowlight => 'blue' }{$!state} // '';
        my $body-row = "  %-{$.w - 2}s";

        # Render character stats into rows of the proper width
        # XXXX: Condition icons (poisoned, low health, etc.)
        my $ascii   = $.parent.parent.ascii;
        my $hp-left = $ascii ?? '*' !! '▆';
        my $mp-left = $ascii ?? '=' !! '▆';
        my $max-hp  = $ascii ?? '+' !! '▃';
        my $max-mp  = $ascii ?? '-' !! '▃';
        my $hp-bar  = sprintf('%-6s', $max-hp x $.character<max-hp>);
        my $mp-bar  = sprintf('%-6s', $max-mp x $.character<max-mp>);
        $hp-bar = $hp-left x $.character<hp> ~ substr($hp-bar, $.character<hp>);
        $mp-bar = $mp-left x $.character<mp> ~ substr($mp-bar, $.character<mp>);

        my @rows = sprintf('%d %-7s %-9s %s %s', $.id,
                           $.character<name>, $.character<class>,
                           $hp-bar, $mp-bar),
                   sprintf($body-row, "Armor:  $.character<armor>, AC $.character<ac>"),
                   sprintf($body-row, "Weapon: $.character<weapon>");

        if $.character<spells> -> @spells {
            my $spells = 'Spells: ' ~ @spells.join(', ');
            @rows.append: wrap-text($.w - 2, $spells, '    ')».fmt($body-row);
        }

        # Draw filled rows into the top of the widget in the proper color
        for @rows.kv -> $y, $row {
            $.grid.set-span(0, $y, $row, $y ?? $color !! ($color ~ $injury).trim);
        }

        # Add some color to the bar graphs
        if $!state ne 'lowlight' {
            $.grid.set-span-color(20, 25, 0, "red$injury");
            $.grid.set-span-color(27, 32, 0, "blue$injury");
        }

        # Clear all remaining rows
        $!rows = +@rows;
        my $blank = ' ' x $.w;
        # my $blank = sprintf $body-row, $.character<name>.uc x 4;  # Compositing debug
        $.grid.set-span(0, $_, $blank, '') for $!rows ..^ $.h;

        record-time("Draw $.w x $.h {self.^name}", $t0);
    }

    #| Show that the character has just been injured, returning a promise to be kept when the effect fades away
    method injured() {
        $!injury-level = 1.0;

        start {
            react {
                whenever Supply.interval(.1) -> $value {
                    $.parent.request-repaint;

                    $!injury-level -= .2;
                    done if $!injury-level < 0;
                }
            }
        }
    }
}


#| Compose a number of CharacterViewers into an overall party widget
class PartyViewer is Overdrawer {
    has $.party;
    has $!expanded = -1;

    has $!repaint-supplier = Supplier.new;
    has $!repaint-supply = $!repaint-supplier.Supply;

    #| Create a CharacterViewer for each character and prepare repaint trigger
    submethod TWEAK() {
        for $!party.members.kv -> $i, $pc {
            CharacterViewer.new(:id($i + 1), :w(self.w), :h(7), :x(0), :y(0),
                                :parent(self), :character($pc));
        }

        # XXXX: Do an initial show-state to avoid a possible race?
        $!repaint-supply.stable(.05).act: -> $print { self.repaint(:$print) }
    }

    #| Tell the CharacterViewers to draw themselves in the correct states
    method draw-children(|) {
        # Set CV states
        for @.children.kv -> $i, $cv {
            $cv.set-state: $!expanded <  0  ?? 'normal'    !!
                           $!expanded == $i ?? 'highlight' !!
                                               'lowlight'  ;
        }

        # Actually draw the CVs
        callsame;
    }

    #| Composite updated CVs
    method draw-frame(|) {
        # Render as a header line followed by composited CharacterViewers
        $.grid.set-span-text(0, 0, '  NAME    CLASS     HEALTH MAGIC ');
        my $y = 1;
        for @.children.kv -> $i, $cv {
            $cv.move-to($cv.x, $y);
            $y += $i == $!expanded ?? $cv.rows + 1 !! 1;

            $cv.composite;
        }

        # Make sure extra rows are cleared after collapsing
        $.grid.set-span(0, $y++, ' ' x $.w, '') for ^(min 6, $.h - $y + 1);
    }

    #| Repaint self, optionally printing result
    method repaint(:$print = True) {
        self.do-frame: Terminal::Print::FrameInfo.new;
        self.composite(:$print);
    }

    #| Request a repaint "soon"
    method request-repaint(:$print = True) {
        $!repaint-supplier.emit($print);
    }

    #| Draw the current party state
    method show-state(:$print = True, :$expand = -1) {
        $!expanded = $expand;
        self.request-repaint(:$print);
    }
}


#| Keep/display a log of events and inputs, scrolling as needed
class LogViewer is Animation {
    has @.log;
    has @.wrapped;
    has $.scroll-pos = 0;
    has $.last-entry-lines = 0;

    #| Draw the currently visible section of the log
    method draw-frame(|) {
        my $t0 = now;

        # Print most recent $.h wrapped lines, highlighting most recent entry
        my $top = max 0, $.scroll-pos - $.h;
        for ^$.h {
            my $line  = @.wrapped[$top + $_] // '';
            my $color = @.wrapped - $top - $_ <= $.last-entry-lines
                        ?? 'bold white' !! '';
            $.grid.set-span(0, $_, $line ~ ' ' x ($.w - $line.chars), $color);
        }

        record-time("Draw $.w x $.h {self.^name}", $t0);
    }

    #| Add a single text entry to the log and optionally print it
    method add-entry($text, :$print = True) {
        my $t0 = now;

        # Update log and wrap lines
        @.log.push($text);
        my @lines = wrap-text($.w, $text, '    ');
        $!last-entry-lines = +@lines;

        # Autoscroll if already at end
        $!scroll-pos += $.last-entry-lines if $.scroll-pos == @.wrapped;
        @.wrapped.append(@lines);

        record-time("Update $.w x $.h {self.^name}", $t0);

        # Render and optionally print and sleep afterwards
        self.do-frame(Terminal::Print::FrameInfo.new);
        self.composite(:$print);
        sleep .5 * @lines if $print && !$*BENCHMARK-MODE;
    }

    #| Simulate a user entering commands at a prompt
    method user-input($prompt, $input, :$print = True) {
        my $text = $prompt;
        self.add-entry($text, :$print);

        for $input.words -> $word {
            @.log.pop;
            @.wrapped.pop;
            self.add-entry($text ~= " $word", :$print);
        }
    }
}


#| Convert a text block (a single multiline string) into a T::P::Grid
sub make-text-grid($text) {
    my @lines = $text.lines;
    my $w     = @lines».chars.max;
    my $h     = @lines.elems;
    my $grid  = Terminal::Print::Grid.new($w, $h);

    $grid.set-span-text(0, $_, @lines[$_]) for ^$h;

    $grid
}


#| Create title animation
sub make-title-animation(ProgressBar :$bar, Bool :$ascii) {
    my $t0 = now;

    my $standard = q:to/STANDARD/;
         ____        _                    __      _    _    _             _       
        |  _ \ _   _(_)_ __  ___    ___  / _|    / \  | | _| |_ __ _ _ __(_) __ _ 
        | |_) | | | | | '_ \/ __|  / _ \| |_    / _ \ | |/ / __/ _` | '__| |/ _` |
        |  _ <| |_| | | | | \__ \ | (_) |  _|  / ___ \|   <| || (_| | |  | | (_| |
        |_| \_\\\\__,_|_|_| |_|___/  \___/|_|   /_/   \_\_|\_\\\\__\__,_|_|  |_|\__,_|
        STANDARD

    my $pagga = q:to/PAGGA/;
        ░█▀▄░█░█░▀█▀░█▀█░█▀▀░░░█▀█░█▀▀░░░█▀█░█░█░▀█▀░█▀█░█▀▄░▀█▀░█▀█░
        ░█▀▄░█░█░░█░░█░█░▀▀█░░░█░█░█▀▀░░░█▀█░█▀▄░░█░░█▀█░█▀▄░░█░░█▀█░
        ░▀░▀░▀▀▀░▀▀▀░▀░▀░▀▀▀░░░▀▀▀░▀░░░░░▀░▀░▀░▀░░▀░░▀░▀░▀░▀░▀▀▀░▀░▀░
        PAGGA

    my $solid = q:to/SOLID/;  # :
        ░███░███░███░███░███░░░███░███░░░███░███░███░███░███░███░███░
        ░███░███░███░███░███░░░███░███░░░███░███░███░███░███░███░███░
        ░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░░░▀▀▀░▀▀▀░░░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░
        SOLID

    #| Make an array of lines filled with $char, the same dimensions as $orig
    sub make-matching-blank($orig, $char = ' ') {
        my $w = $orig.lines[0].chars;
        my $h = $orig.lines.elems;

        ($char x $w ~ "\n") x $h;
    }

    #| Turn several multiline text blocks into an array of T::P::Grid objects
    sub make-text-grids(*@texts) {
        my @grids = @texts.map(&make-text-grid);
    }

    # Matching keyframes make it appear as if the animation paused, even though
    # in reality it is continuously running from the first frame to the last
    my $grids = $ascii ?? make-text-grids($standard, $standard, $standard, make-matching-blank($standard))
                       !! make-text-grids($solid, $pagga, $pagga, $pagga, make-matching-blank($pagga));

    # Center the title animation in the upper 3/4 of the screen
    my $title-w = $grids[0].columns;
    my $title-h = $grids[0].rows;
    my $title-x = floor (w       - $title-w) / 2;
    my $title-y = floor (h * 3/4 - $title-h) / 2;  # ==
    my $anim    = KeyframeAnimation.new(:keyframes(|$grids),
                                        :x($title-x), :y($title-y),  # ,
                                        :w($title-w), :h($title-h));

    $anim.on-keyframe.Supply.tap: { $bar.add-progress(60 / ($grids - 1)) if $^frame }
    $bar.add-progress(5);

    # Make total animation time relatively constant, despite different number
    # of keyframes and different size for ASCII and full Unicode versions
    # Note: interval supplies need an interval >= 1 ms
    my $seconds-per-frame = 6 / ($grids * $title-w * $title-h);
    my $promise = $anim.speckle($*BENCHMARK-MODE ?? .001 !! $seconds-per-frame);
    record-time("Setup animation for $title-w x $title-h title screen", $t0);

    $promise;
}


#| The main user interface composed of map, party, and log panels
class UI is Widget
 does Terminal::Print::BoxDrawing {
    has Int         $.color-bits;
    has Bool        $.edge-border = False;
    has Bool        $.narrow = False;
    has Bool        $.ascii;
    has Game        $.game;
    has ProgressBar $.bar;
    has PartyViewer $.pv;
    has MapViewer   $.mv;
    has LogViewer   $.lv;

    #| Lay out main UI subwidgets and dividing lines, updating the progress bar
    method build-layout() {
        # Basic 3-viewport layout (map, party stats, log/input)
        my $party-width = 33;
        my $log-height  = $.h div 6;
        my $h-break     = $.w - $