Skip to content

Instantly share code, notes, and snippets.

@VictorTaelin
Created January 26, 2026 19:19
Show Gist options
  • Select an option

  • Save VictorTaelin/96837239b1ca0d5f145f388540fdc9e3 to your computer and use it in GitHub Desktop.

Select an option

Save VictorTaelin/96837239b1ca0d5f145f388540fdc9e3 to your computer and use it in GitHub Desktop.
import * as Assets from "./assets";
import * as BattleState from "./BattleState";
import * as DialogState from "./DialogState";
import * as Entity from "./Entity";
import * as Floor from "./Floor";
import * as Font from "./Font";
import * as GameMap from "./GameMap";
import * as Glyph from "./Glyph";
import * as Maths from "./Math";
import * as Menu from "./Menu";
import * as Move from "./Move";
import * as Pos from "./Pos";
import * as Specie from "./Specie";
import * as Types from "./types";
const tile_sz = Types.tile_size;
const view_cols = Types.view_cols;
const view_rows = Types.view_rows;
const font_sz = 8;
const view_focus_x = 4;
const view_focus_y = 4;
const dir_idx: Record<Types.Direction, number> = {
down: 0,
left: 1,
up: 2,
right: 3
};
const battle_sprite_cache = new Map<string, HTMLCanvasElement>();
// Draw a sprite id or fallback square.
function draw_sprite(
ctx: CanvasRenderingContext2D,
id: string,
x: number,
y: number,
size: number
): void {
const img = Assets.get_image(id);
if (Assets.is_image_ready(img)) {
ctx.drawImage(img, x, y, size, size);
return;
}
ctx.fillStyle = "#ff00ff";
ctx.fillRect(x, y, size, size);
}
// Draw a single floor sprite.
export function draw_floor(
ctx: CanvasRenderingContext2D,
id: string,
sx: number,
sy: number
): void {
draw_sprite(ctx, id, sx, sy, tile_sz);
}
// Draw a single entity sprite.
export function draw_ent(
ctx: CanvasRenderingContext2D,
spr: string,
sx: number,
sy: number,
dir: Types.Direction,
frame: number
): void {
const key = ent_sprite_id(spr, dir, frame);
draw_sprite(ctx, key, sx, sy, tile_sz);
}
// Build a sprite id for an entity.
function ent_sprite_id(
spr: string,
dir: Types.Direction,
frame: number
): string {
if (!spr.startsWith("ent_")) {
return Floor.sprite_id(spr, frame, dir_idx[dir]);
}
let dir_name = "front";
if (dir === "up") {
dir_name = "back";
}
if (dir === "left") {
dir_name = "left";
}
if (dir === "right") {
dir_name = "right";
}
const step = frame !== 0;
let suffix = "stand";
if (dir_name === "left" || dir_name === "right") {
suffix = step ? "step" : "stand";
} else if (step) {
suffix = frame === 1 ? "step0" : "step1";
}
return `${spr}_${dir_name}_${suffix}`;
}
// Build a bordered character grid.
function box_grid(cols: number, rows: number): string[][] {
const grid: string[][] = [];
for (let y = 0; y < rows; y++) {
const row: string[] = [];
for (let x = 0; x < cols; x++) {
let ch = " ";
if (y === 0 && x === 0) {
ch = "┌";
} else if (y === 0 && x === cols - 1) {
ch = "┐";
} else if (y === rows - 1 && x === 0) {
ch = "└";
} else if (y === rows - 1 && x === cols - 1) {
ch = "┘";
} else if (y === 0 || y === rows - 1) {
ch = "─";
} else if (x === 0) {
ch = "│";
} else if (x === cols - 1) {
ch = "|";
}
row.push(ch);
}
grid.push(row);
}
return grid;
}
// Write a line into a grid row.
function grid_write(
grid: string[][],
row: number,
col: number,
text: string,
max: number
): void {
const line = grid[row];
if (!line) {
return;
}
const limit = Math.min(max, line.length - col);
if (limit <= 0) {
return;
}
let slice = text;
if (slice.length > limit) {
slice = slice.slice(0, limit);
}
for (let i = 0; i < slice.length; i++) {
line[col + i] = slice[i];
}
}
// Join grid rows into string lines.
function grid_lines(grid: string[][]): string[] {
const lines: string[] = [];
for (let y = 0; y < grid.length; y++) {
lines.push(grid[y].join(""));
}
return lines;
}
// Build a grid from string lines.
function grid_from_lines(lines: string[]): string[][] {
const grid: string[][] = [];
for (let i = 0; i < lines.length; i++) {
grid.push(lines[i].split(""));
}
return grid;
}
// Insert string lines into a grid at a start row.
function grid_put_lines(
grid: string[][],
start_y: number,
lines: string[]
): void {
for (let i = 0; i < lines.length; i++) {
grid[start_y + i] = lines[i].split("");
}
}
// Overlay lines into a grid and return joined lines.
function grid_overlay(
grid: string[][],
start_y: number,
lines: string[]
): string[] {
grid_put_lines(grid, start_y, lines);
return grid_lines(grid);
}
// Build a dialog grid with two lines of text.
function dialog_grid_text(
top: string,
bot: string,
cols: number,
rows: number
): string[] {
const grid = box_grid(cols, rows);
const start_x = 1;
const max = cols - 2;
const row_top = 2;
const last_inner = rows - 2;
if (row_top > last_inner) {
return grid_lines(grid);
}
const row_bot = Math.min(4, last_inner);
grid_write(grid, row_top, start_x, top, max);
grid_write(grid, row_bot, start_x, bot, max);
return grid_lines(grid);
}
// Build the dialog grid text for this tick.
function dialog_text(
dlg: Types.DialogState,
tick: number,
cols: number,
rows: number
): string[] {
const prog = DialogState.dlg_progress(dlg, tick);
const top = prog.top_ln.slice(0, prog.top_seen);
const bot = prog.bot_ln.slice(0, prog.bot_seen);
return dialog_grid_text(top, bot, cols, rows);
}
// Build a full dialog grid text from a dialog.
function dialog_text_full(
dlg: Types.Dialog,
cols: number,
rows: number
): string[] {
const para = dlg[0] || [];
const top = para[0] || "";
const bot = para[1] || "";
return dialog_grid_text(top, bot, cols, rows);
}
// Draw a font sprite at a grid cell.
function draw_font(
ctx: CanvasRenderingContext2D,
id: string,
x: number,
y: number
): void {
draw_sprite(ctx, id, x, y, font_sz);
}
// Draw a grid of font sprites.
function draw_font_grid(
ctx: CanvasRenderingContext2D,
lines: string[],
x: number,
y: number
): void {
for (let gy = 0; gy < lines.length; gy++) {
const line = lines[gy];
for (let gx = 0; gx < line.length; gx++) {
const ch = line[gx];
const id = Font.font_char_id(ch);
const sx = x + gx * font_sz;
const sy = y + gy * font_sz;
draw_font(ctx, id, sx, sy);
}
}
}
// Draw a prebuilt box grid at a position.
function draw_box_lines(
ctx: CanvasRenderingContext2D,
lines: string[],
x: number,
y: number
): void {
draw_font_grid(ctx, lines, x, y);
}
// Draw a dialog box within a specific rectangle.
function draw_dialog_box(
ctx: CanvasRenderingContext2D,
dlg: Types.DialogState,
tick: number,
x: number,
y: number,
w: number,
h: number
): void {
const cols = Math.floor(w / font_sz);
const rows = Math.floor(h / font_sz);
const lines = dialog_text(dlg, tick, cols, rows);
draw_box_lines(ctx, lines, x, y);
}
// Build the start menu grid text.
function menu_text(menu: { selected_index: number }): string[] {
const cols = 10;
const rows = 16;
const grid = box_grid(cols, rows);
const start_x = 2;
const max = 7;
const start_y = 2;
const gap = 2;
for (let i = 0; i < Menu.items.length; i++) {
const row = start_y + i * gap;
if (row >= rows - 1) {
continue;
}
grid_write(grid, row, start_x, Menu.items[i], max);
}
const sel = menu.selected_index;
if (sel >= 0 && sel < Menu.items.length) {
const row = start_y + sel * gap;
if (row < rows - 1) {
grid_write(grid, row, 1, "▶", 1);
}
}
return grid_lines(grid);
}
// Pad or trim text to a fixed width on the right.
function text_pad_right(text: string, width: number): string {
return text.slice(0, width).padEnd(width, " ");
}
// Pad text on the left to a minimum width.
function text_pad_left(text: string, width: number): string {
return text.padStart(width, " ");
}
// Build a fixed width battle name.
function battle_name_text(name: string, max: number): string {
return text_pad_right(name, max);
}
// Build a fixed width battle level string.
function battle_level_text(level: number): string {
return text_pad_right(`L${level}`, 4);
}
// Build a fixed width battle HP string.
function battle_hp_text(curr: number, max: number): string {
const left = text_pad_left(`${curr}`, 3);
const right = text_pad_left(`${max}`, 3);
return `${left}/${right}`;
}
// Build the battle moves grid text.
function battle_moves_text(battle: Types.BattleState): string[] {
const cols = 20;
const rows = 6;
const grid = box_grid(cols, rows);
const moves = battle.player.moves;
const max = cols - 3;
for (let i = 0; i < moves.length && i < 4; i++) {
const move = Move.move_get(moves[i]);
const row = 1 + i;
grid_write(grid, row, 2, move.name, max);
}
const sel = battle.move_index;
if (sel >= 0 && sel < 4) {
const row = 1 + sel;
if (row < rows - 1) {
grid_write(grid, row, 1, "▶", 1);
}
}
return grid_lines(grid);
}
// Build the battle screen text.
function battle_text(
st: Types.GameState,
tick: number,
instant: boolean
): string[] {
const battle = st.battle;
if (!battle) {
return [];
}
const layout = [
" ABCDEFGHIJ %%%%%%% ",
" L999 %%%%%%% ",
" │HP====== %%%%%%% ",
" <────────> %%%%%%% ",
" ######## %%%%%%% ",
" ######## %%%%%%% ",
" ######## %%%%%%% ",
" ######## ABCDEFGHIJ",
" ######## L999 ",
" ######## HP======| ",
" ######## 999/999| ",
" ########<────────> ",
"┌───────┌──────────┐",
"│ │ │",
"│ │▶FIGHT MON│",
"│ │ │",
"│ │ ITEM RUN│",
"└───────└──────────┘"
];
const grid = grid_from_lines(layout);
const enemy_name = battle_name_text(battle.enemy.nick, 10);
const enemy_level = battle_level_text(battle.enemy.level);
grid_write(grid, 0, 1, enemy_name, 10);
grid_write(grid, 1, 4, enemy_level, 4);
const player_name = battle_name_text(battle.player.nick, 10);
const player_level = battle_level_text(battle.player.level);
grid_write(grid, 7, 10, player_name, 10);
grid_write(grid, 8, 14, player_level, 4);
let player_hp = battle.player.current_hp;
if (!instant) {
player_hp = battle_hp_value(battle, "player", tick);
}
const hp_text = battle_hp_text(player_hp, battle.player.max_hp);
grid_write(grid, 10, 11, hp_text, 7);
if (st.dialog) {
const lines = dialog_text(st.dialog, tick, 20, 6);
return grid_overlay(grid, 12, lines);
}
if (battle.step === "moves") {
const lines = battle_moves_text(battle);
return grid_overlay(grid, 12, lines);
}
if (battle.step === "menu") {
const base_x = 9;
const base_y = 14;
for (let row = 0; row < 2; row++) {
for (let col = 0; col < 2; col++) {
const x = base_x + col * 6;
const y = base_y + row * 2;
grid[y][x] = " ";
}
}
const sel_x = base_x + battle.menu.col * 6;
const sel_y = base_y + battle.menu.row * 2;
grid[sel_y][sel_x] = "▶";
return grid_lines(grid);
}
if (battle.step === "anim" || battle.step === "hp") {
const action = battle.actions[battle.action_index];
if (action) {
const dlg = BattleState.battle_action_dialog(battle, action);
const lines = dialog_text_full(dlg, 20, 6);
return grid_overlay(grid, 12, lines);
}
}
const blank = grid_lines(box_grid(20, 6));
return grid_overlay(grid, 12, blank);
}
// Find the bounding rect for a battle sprite placeholder.
function battle_rect(
lines: string[],
target: string
): { x: number; y: number; w: number; h: number } | null {
let min_x = lines[0].length;
let min_y = lines.length;
let max_x = -1;
let max_y = -1;
for (let y = 0; y < lines.length; y++) {
const line = lines[y];
for (let x = 0; x < line.length; x++) {
if (line[x] !== target) {
continue;
}
if (x < min_x) {
min_x = x;
}
if (y < min_y) {
min_y = y;
}
if (x > max_x) {
max_x = x;
}
if (y > max_y) {
max_y = y;
}
}
}
if (max_x < 0 || max_y < 0) {
return null;
}
return {
x: min_x,
y: min_y,
w: max_x - min_x + 1,
h: max_y - min_y + 1
};
}
// Remove the top row of a battle placeholder.
function battle_trim_row(lines: string[], target: string): string[] {
const rect = battle_rect(lines, target);
if (!rect) {
return lines;
}
const row = lines[rect.y];
if (!row) {
return lines;
}
const chars = row.split("");
for (let x = rect.x; x < rect.x + rect.w; x++) {
if (chars[x] === target) {
chars[x] = " ";
}
}
const next = [...lines];
next[rect.y] = chars.join("");
return next;
}
// Compute an animated HP value for a side.
function battle_hp_value(
battle: Types.BattleState,
side: Types.BattleSide,
tick: number
): number {
const anim = battle.hp_anim;
if (anim && anim.side === side) {
const elapsed = tick - anim.start_tick;
const ratio = Maths.clamp(elapsed / anim.duration, 0, 1);
const delta = anim.to - anim.from;
return anim.from + Math.floor(delta * ratio);
}
const mon = BattleState.battle_mon_get(battle, side);
return mon.current_hp;
}
// Compute an hp ratio for a battle side.
function battle_hp_ratio(
battle: Types.BattleState,
side: Types.BattleSide,
tick: number
): number {
const mon = BattleState.battle_mon_get(battle, side);
if (mon.max_hp <= 0) {
return 0;
}
const hp = battle_hp_value(battle, side, tick);
return hp / mon.max_hp;
}
// Build fill values for a 6-tile ratio bar.
function bar_fill_levels(ratio: number): number[] {
const levels: number[] = [];
const total = Math.floor(Maths.clamp(ratio, 0, 1) * 48);
for (let i = 0; i < 6; i++) {
const fill = Maths.clamp(total - i * 8, 0, 8);
levels.push(fill);
}
return levels;
}
// Build a list of hp bar tile fills for a ratio.
function battle_hp_tiles(ratio: number): number[] {
return bar_fill_levels(ratio);
}
// Write hp bar tiles into a map for a battle side.
function battle_hp_write(
map: Map<string, string>,
battle: Types.BattleState,
tick: number,
side: Types.BattleSide,
start_x: number,
row: number
): void {
const ratio = battle_hp_ratio(battle, side, tick);
const tiles = battle_hp_tiles(ratio);
for (let i = 0; i < tiles.length; i++) {
map.set(`${start_x + i},${row}`, `hpbar_${tiles[i]}`);
}
}
// Build a map of hp bar tiles for the battle grid.
function battle_hp_map(
battle: Types.BattleState,
tick: number
): Map<string, string> {
const map = new Map<string, string>();
battle_hp_write(map, battle, tick, "enemy", 4, 2);
battle_hp_write(map, battle, tick, "player", 12, 9);
return map;
}
// Decide whether to hide a battle sprite on a flicker tick.
function battle_sprite_hide(
battle: Types.BattleState,
tick: number,
side: Types.BattleSide
): boolean {
void battle;
void tick;
void side;
return false;
}
// Compute the current frame for a battle anim.
function battle_anim_frame(anim: Types.BattleAnim, tick: number): number {
const elapsed = tick - anim.start_tick;
if (elapsed <= 0) {
return 0;
}
const ratio = Maths.clamp(elapsed / anim.duration, 0, 0.999);
if (ratio < 0.5) {
return 0;
}
return 1;
}
// Draw a battle animation sprite.
function battle_draw_anim(
ctx: CanvasRenderingContext2D,
anim: Types.BattleAnim,
tick: number,
rect: { x: number; y: number; w: number; h: number },
y_off: number
): void {
const size = font_sz * 2;
const rect_w = rect.w * font_sz;
const rect_h = rect.h * font_sz;
const px = rect.x * font_sz + Math.floor((rect_w - size) / 2);
const py = rect.y * font_sz + Math.floor((rect_h - size) / 2) + y_off;
const frame = battle_anim_frame(anim, tick);
if (anim.kind === "heal") {
const id = `atk_fire_move_${frame}_00_00`;
const img = Assets.get_image(id);
if (Assets.is_image_ready(img)) {
ctx.drawImage(img, px, py, size, size);
}
return;
}
for (let y = 0; y < 2; y++) {
for (let x = 0; x < 2; x++) {
const id = Floor.sprite_id(`atk_smog_${frame}`, x, y);
const img = Assets.get_image(id);
if (!Assets.is_image_ready(img)) {
continue;
}
ctx.drawImage(
img,
px + x * font_sz,
py + y * font_sz,
font_sz,
font_sz
);
}
}
}
// Draw a battle sprite or fallback square.
function battle_draw_sprite(
ctx: CanvasRenderingContext2D,
sprite_id: string,
color: string,
rect: { x: number; y: number; w: number; h: number },
y_off: number
): void {
const img = Assets.get_image(sprite_id);
const px = rect.x * font_sz;
const py = rect.y * font_sz + y_off;
const w = rect.w * font_sz;
const h = rect.h * font_sz;
if (Assets.is_image_ready(img)) {
let canvas = battle_sprite_cache.get(sprite_id);
if (!canvas) {
canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const cctx = canvas.getContext("2d");
if (!cctx) {
ctx.drawImage(img, px, py, w, h);
return;
}
cctx.drawImage(img, 0, 0);
const data = cctx.getImageData(0, 0, canvas.width, canvas.height);
const pix = data.data;
for (let i = 0; i < pix.length; i += 4) {
const r = pix[i];
const g = pix[i + 1];
const b = pix[i + 2];
if (r === 255 && g === 255 && b === 255) {
pix[i + 3] = 0;
}
}
cctx.putImageData(data, 0, 0);
battle_sprite_cache.set(sprite_id, canvas);
}
ctx.drawImage(canvas, px, py, w, h);
return;
}
ctx.fillStyle = color;
ctx.fillRect(px, py, w, h);
}
// Build a sprite id for a battle mon.
function battle_mon_sprite_id(
specie_id: Types.SpecieId,
side: Types.BattleSide
): string {
const base = Specie.specie_sprite(specie_id);
if (side === "enemy") {
return `mon_front_${base}_0`;
}
return `mon_back_${base}_0`;
}
// Draw the battle screen.
function draw_battle(
ctx: CanvasRenderingContext2D,
st: Types.GameState,
tick: number
): void {
const battle = st.battle;
if (!battle) {
return;
}
const canvas = ctx.canvas;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const lines = battle_text(st, tick, false);
if (lines.length === 0) {
return;
}
const hp_map = battle_hp_map(battle, tick);
for (let gy = 0; gy < lines.length; gy++) {
const line = lines[gy];
for (let gx = 0; gx < line.length; gx++) {
const key = `${gx},${gy}`;
const hp_id = hp_map.get(key);
const sx = gx * font_sz;
const sy = gy * font_sz;
if (hp_id) {
draw_font(ctx, hp_id, sx, sy);
continue;
}
let ch = line[gx];
if (ch === "#" || ch === "%") {
ch = " ";
}
if (ch === "'") {
if (gx < Math.floor(line.length / 2)) {
draw_font(ctx, Font.font_char_id("<"), sx, sy);
} else {
draw_font(ctx, Font.font_char_id(">"), sx, sy);
}
continue;
}
const id = Font.font_char_id(ch);
draw_font(ctx, id, sx, sy);
}
}
const enemy_rect = battle_rect(lines, "%");
const player_rect = battle_rect(lines, "#");
const enemy_id = battle_mon_sprite_id(battle.enemy.specie_id, "enemy");
const player_id = battle_mon_sprite_id(battle.player.specie_id, "player");
if (enemy_rect && !battle_sprite_hide(battle, tick, "enemy")) {
battle_draw_sprite(ctx, enemy_id, battle.enemy.color, enemy_rect, 0);
}
if (player_rect && !battle_sprite_hide(battle, tick, "player")) {
battle_draw_sprite(
ctx,
player_id,
battle.player.color,
player_rect,
font_sz
);
}
if (battle.anim) {
const anim = battle.anim;
const rect = anim.side === "enemy" ? enemy_rect : player_rect;
if (rect) {
const y_off = anim.side === "player" ? font_sz : 0;
battle_draw_anim(ctx, anim, tick, rect, y_off);
}
}
}
// Overlay text rows into a base grid.
function overlay_lines(
base: string[],
overlay: string[],
start_x: number,
start_y: number
): string[] {
const next = [...base];
for (let y = 0; y < overlay.length; y++) {
const row = start_y + y;
if (row < 0 || row >= next.length) {
continue;
}
const base_row = [...next[row]];
while (base_row.length < start_x) {
base_row.push(" ");
}
const over_row = [...overlay[y]];
for (let x = 0; x < over_row.length; x++) {
base_row[start_x + x] = over_row[x];
}
next[row] = base_row.join("");
}
return next;
}
// Build the raw terminal grid for the map screen.
function map_raw_lines(st: Types.GameState, tick: number): string[] {
let lines: string[] = [];
const start_x = st.player_pos.x - view_focus_x;
const start_y = st.player_pos.y - view_focus_y;
const cell_w = 4;
const line_w = view_cols * cell_w;
for (let y = 0; y < view_rows; y++) {
let ent_line = "";
let floor_line = "";
for (let x = 0; x < view_cols; x++) {
const wx = start_x + x;
const wy = start_y + y;
const pos = Pos.pos(wx, wy);
const tile = GameMap.map_get(st.map, pos);
let floor_id = "tile_grass_00_00";
if (tile) {
floor_id = tile.floor;
}
const floor = Glyph.glyph_floor(floor_id);
let ent = Glyph.glyph_empty();
if (tile && tile.entity) {
ent = Glyph.glyph_entity(tile.entity);
}
if (Pos.pos_eq(pos, st.player_pos)) {
let dir: Types.Direction = "down";
if (tile && tile.entity) {
dir = tile.entity.direction;
}
ent = Glyph.glyph_player(dir);
}
ent_line += raw_cell(ent);
floor_line += raw_cell(floor);
}
lines.push(ent_line);
lines.push(floor_line);
}
if (st.menu) {
const menu_lines = raw_expand_lines(menu_text(st.menu));
const menu_x = 5 * cell_w;
lines = raw_overlay_lines(lines, menu_lines, menu_x, 0, line_w);
}
if (st.dialog) {
const dlg_lines = dialog_text(st.dialog, tick, 20, 6);
const expanded = raw_expand_lines(dlg_lines);
const start = lines.length - expanded.length;
for (let i = 0; i < expanded.length; i++) {
const row = start + i;
if (row >= 0 && row < lines.length) {
lines[row] = expanded[i];
}
}
}
return lines;
}
// Pad a glyph with spaces to match the raw terminal width.
function raw_cell(glyph: string): string {
return ` ${glyph} `;
}
// Detect if a glyph should be treated as emoji width.
function raw_is_emoji_glyph(glyph: string): boolean {
if (!glyph) {
return false;
}
if (glyph.includes("\u200D") || glyph.includes("\uFE0F")) {
return true;
}
const code = glyph.codePointAt(0);
if (!code) {
return false;
}
if (code >= 0x1f000) {
return true;
}
return code >= 0x2600 && code <= 0x27bf;
}
// Return the display width of a glyph.
function raw_char_width(glyph: string): number {
if (raw_is_emoji_glyph(glyph)) {
return 2;
}
return 1;
}
// Convert a line into a display column buffer.
function raw_line_cols(line: string, width: number): (string | null)[] {
const cols: (string | null)[] = new Array(width).fill(" ");
let col = 0;
for (const ch of line) {
const w = raw_char_width(ch);
if (col >= width) {
break;
}
cols[col] = ch;
if (w === 2 && col + 1 < width) {
cols[col + 1] = null;
}
col += w;
}
return cols;
}
// Convert a display column buffer back into a line.
function raw_cols_line(cols: (string | null)[]): string {
let out = "";
for (const cell of cols) {
if (cell === null) {
continue;
}
out += cell;
}
return out;
}
// Overlay one line into another by display columns.
function raw_overlay_line(
base: string,
overlay: string,
start_x: number,
width: number
): string {
const base_cols = raw_line_cols(base, width);
const over_cols = raw_line_cols(overlay, width);
for (let i = 0; i < width; i++) {
const idx = start_x + i;
if (idx < 0 || idx >= width) {
continue;
}
const cell = over_cols[i];
if (cell === null) {
base_cols[idx] = null;
continue;
}
base_cols[idx] = cell;
}
return raw_cols_line(base_cols);
}
// Overlay multiple lines by display columns.
function raw_overlay_lines(
base: string[],
overlay: string[],
start_x: number,
start_y: number,
width: number
): string[] {
const next = [...base];
for (let y = 0; y < overlay.length; y++) {
const row = start_y + y;
if (row < 0 || row >= next.length) {
continue;
}
next[row] = raw_overlay_line(next[row], overlay[y], start_x, width);
}
return next;
}
const raw_expand_map: Record<string, string> = {
" ": " ",
"░": " ",
"▏": "▎ ",
"▎": "▌ ",
"▍": "▊ ",
"▌": "█ ",
"▋": "█▎",
"▊": "█▌",
"▉": "█▊",
"█": "██",
"#": "# ",
"-": "--",
"─": "──",
"┌": "┌─",
"┐": "─┐",
"└": "└─",
"┘": "─┘"
};
// Expand a battle line so every tile is 2 characters wide.
function raw_expand_line(line: string): string {
let out = "";
const chars = [...line];
const last = chars.length - 1;
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
if (ch === "│" || ch === "|") {
if (i === last) {
out += " │";
} else {
out += "│ ";
}
continue;
}
const mapped = raw_expand_map[ch];
if (mapped) {
out += mapped;
continue;
}
out += `${ch} `;
}
return out;
}
// Expand multiple lines to double width.
function raw_expand_lines(lines: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < lines.length; i++) {
out.push(raw_expand_line(lines[i]));
}
return out;
}
// Convert a bar ratio to placeholder tiles.
function raw_bar_tiles(ratio: number): string[] {
const tiles: string[] = [];
const glyphs = ["░", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
const fills = bar_fill_levels(ratio);
for (let i = 0; i < fills.length; i++) {
tiles.push(glyphs[fills[i]]);
}
return tiles;
}
// Place a bar placeholder row into a battle line.
function raw_place_bar(
lines: string[],
row: number,
col: number,
ratio: number
): string[] {
if (row < 0 || row >= lines.length) {
return lines;
}
const tiles = raw_bar_tiles(ratio);
const next = [...lines];
const line = [...next[row]];
for (let i = 0; i < tiles.length; i++) {
const idx = col + i;
if (idx >= 0 && idx < line.length) {
line[idx] = tiles[i];
}
}
next[row] = line.join("");
return next;
}
// Build the raw terminal output for the current frame.
export function on_draw_raw(
st: Types.GameState,
tick: number
): string {
if (st.battle) {
let lines = battle_text(st, tick, false);
const battle = st.battle;
if (!battle) {
return "";
}
lines = battle_trim_row(lines, "#");
const enemy_ratio = battle_hp_ratio(battle, "enemy", tick);
const player_ratio = battle_hp_ratio(battle, "player", tick);
lines = raw_place_bar(lines, 2, 4, enemy_ratio);
lines = raw_place_bar(lines, 9, 12, player_ratio);
const wide = raw_expand_lines(lines);
return wide.join("\n");
}
return map_raw_lines(st, tick).join("\n");
}
// Render the full frame.
export function on_draw(
ctx: CanvasRenderingContext2D,
st: Types.GameState,
tick: number
): void {
if (st.battle) {
draw_battle(ctx, st, tick);
return;
}
const canvas = ctx.canvas;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const map = st.map;
const p_tile = st.player_pos;
const p_data = GameMap.map_get(map, p_tile);
let p_pos = p_tile;
if (p_data && p_data.entity) {
p_pos = Entity.ent_pos(p_data.entity, tick);
}
const off_x = view_focus_x * tile_sz - p_pos.x * tile_sz;
const off_y = view_focus_y * tile_sz - p_pos.y * tile_sz;
const start_x = p_tile.x - view_focus_x;
const start_y = p_tile.y - view_focus_y;
const ents: Types.Entity[] = [];
for (let y = 0; y < view_rows; y++) {
for (let x = 0; x < view_cols; x++) {
const wx = start_x + x;
const wy = start_y + y;
const pos_xy = Pos.pos(wx, wy);
const tile = GameMap.map_get(map, pos_xy);
let f_id = "tile_grass_00_00";
if (tile) {
f_id = tile.floor;
if (tile.entity) {
ents.push(tile.entity);
}
}
const sx = wx * tile_sz + off_x;
const sy = wy * tile_sz + off_y;
draw_floor(ctx, f_id, sx, sy);
}
}
for (const ent of ents) {
if (ent.sprite_name === "wall") {
continue;
}
const pos_xy = Entity.ent_pos(ent, tick);
const sx = pos_xy.x * tile_sz + off_x;
const sy = pos_xy.y * tile_sz + off_y;
let frame = 0;
const elapsed = tick - ent.last_tick;
if (elapsed > 0 && elapsed < Types.move_ticks) {
const step = Math.floor((elapsed / Types.move_ticks) * 2);
frame = step + 1;
}
draw_ent(ctx, ent.sprite_name, sx, sy, ent.direction, frame);
}
const dlg = st.dialog;
if (dlg) {
const dlg_h = tile_sz * 3;
const dlg_y = canvas.height - dlg_h;
draw_dialog_box(ctx, dlg, tick, 0, dlg_y, canvas.width, dlg_h);
}
const menu = st.menu;
if (menu) {
const menu_x = 5 * tile_sz;
const menu_y = 0;
const lines = menu_text(menu);
draw_box_lines(ctx, lines, menu_x, menu_y);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment