Created
January 26, 2026 19:19
-
-
Save VictorTaelin/96837239b1ca0d5f145f388540fdc9e3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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