diff --git a/.fallowrc.json b/.fallowrc.json index 037b4f9..f1b7801 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/fallow/schema.json", "entry": ["test/**/*.bench.ts"], - "ignorePatterns": ["graphify-out/**", "test/fixtures/magicland/.visual-output/**"], + "ignorePatterns": ["graphify-out/**", "assets/**", "test/fixtures/magicland/.visual-output/**"], "cache": { "dir": "node_modules/.cache/fallow", "maxSizeMb": 64 diff --git a/assets/devto-roundtrip/.gitignore b/assets/devto-roundtrip/.gitignore new file mode 100644 index 0000000..657c910 --- /dev/null +++ b/assets/devto-roundtrip/.gitignore @@ -0,0 +1,4 @@ +bundle.js +*.js.map +*.js +frames/ diff --git a/assets/devto-roundtrip/KENNEY-LICENSE.txt b/assets/devto-roundtrip/KENNEY-LICENSE.txt new file mode 100644 index 0000000..35afdd3 --- /dev/null +++ b/assets/devto-roundtrip/KENNEY-LICENSE.txt @@ -0,0 +1,5 @@ +Images in this folder are packed from Kenney asset packs (https://kenney.nl): +New Platformer Pack, Particle Pack, Hexagon Pack and Isometric Blocks. + +License: Creative Commons Zero, CC0 1.0 +https://creativecommons.org/publicdomain/zero/1.0/ diff --git a/assets/devto-roundtrip/README.md b/assets/devto-roundtrip/README.md new file mode 100644 index 0000000..202c0eb --- /dev/null +++ b/assets/devto-roundtrip/README.md @@ -0,0 +1,23 @@ +# Tiled → PixiJS → Tiled recording + +Material for the dev.to article in `devto-article.md`. The runnable version of the demo, for StackBlitz, is `examples/tiled-roundtrip`. + +Run from the repository root: + +```sh +npx tsdown +node assets/devto-roundtrip/prepare.mjs +node assets/devto-roundtrip/capture-browser.mjs +cp assets/devto-roundtrip/frames/export/level-edited.tmj assets/devto-roundtrip/ +pwsh assets/devto-roundtrip/capture-tiled.ps1 +python assets/devto-roundtrip/compose.py # needs Pillow +``` + +- `prepare.mjs` writes `level.tmj` and bundles `main.ts` into `bundle.js`. +- `capture-browser.mjs` drives the demo in headless Chrome (SwiftShader) over CDP: four real clicks on the bridge cells and a real export click. It saves one capture per state and the downloaded `level-edited.tmj`. The export was checked to contain exactly the four Bridge tiles at (7..10, 6). +- `capture-tiled.ps1` opens the maps in Tiled 1.12.2 with the Bridge layer selected and captures the window in physical pixels: `level.tmj`, the exported `level-edited.tmj`, and the same file with *View › Highlight Current Layer* on. It is set up for this machine (150 % scaling on the second 4K display) and restores Tiled's session file and registry preferences afterwards. +- `compose.py` builds `pixi-tiledmap-roundtrip.gif` (1000×792, ~14 s loop, ~1.6 MB) and `devto-cover.png` (1000×420). + +The map is at the same pixel position and size (864×480) in the Tiled and browser captures, so the crossfades change only the tool around it. The browser crop leaves out the page heading. Everything inside the window frames is captured pixels. Drawn in post: the header, the window labels, the code line, the pointer and click ripples, the flying file chip, the Layers panel placed over the map view (a crop of Tiled's own Layers dock), and the green outline around the bridge. + +Artwork: Kenney New Platformer Pack, CC0; see KENNEY-LICENSE.txt. diff --git a/assets/devto-roundtrip/capture-browser.mjs b/assets/devto-roundtrip/capture-browser.mjs new file mode 100644 index 0000000..ec18a51 --- /dev/null +++ b/assets/devto-roundtrip/capture-browser.mjs @@ -0,0 +1,94 @@ +// Drives the real demo in headless Chrome over CDP: four bridge clicks, one +// export. Writes one capture per state to frames/ and the exported map to +// frames/export/level-edited.tmj. Run from the repository root after prepare.mjs. +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +const frames = join(root, 'frames'); +const downloads = join(frames, 'export'); +const chromePath = join(process.env.PROGRAMFILES ?? '', 'Google/Chrome/Application/chrome.exe'); +const port = 9333; +const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); + +await rm(downloads, { recursive: true, force: true }); +await mkdir(downloads, { recursive: true }); + +const server = spawn(process.execPath, [join(root, 'serve.mjs')], { stdio: 'ignore' }); +const profile = await mkdtemp(join(tmpdir(), 'roundtrip-chrome-')); +const chrome = spawn(chromePath, [ + '--headless=new', '--hide-scrollbars', '--disable-gpu', `--remote-debugging-port=${port}`, + `--user-data-dir=${profile}`, '--window-size=1100,760', 'about:blank', +]); + +try { + let targets; + for (let i = 0; i < 50 && !targets; i++) { + await sleep(200); + targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) => r.json()).catch(() => undefined); + } + const socket = new WebSocket(targets.find((t) => t.type === 'page').webSocketDebuggerUrl); + await new Promise((done) => socket.addEventListener('open', done, { once: true })); + let nextId = 0; + const pending = new Map(); + socket.addEventListener('message', ({ data }) => { + const message = JSON.parse(data); + if (message.id && pending.has(message.id)) { + const { done, fail } = pending.get(message.id); + pending.delete(message.id); + message.error ? fail(new Error(message.error.message)) : done(message.result); + } + }); + const send = (method, params = {}) => new Promise((done, fail) => { + const id = ++nextId; + pending.set(id, { done, fail }); + socket.send(JSON.stringify({ id, method, params })); + }); + const evaluate = async (expression) => + (await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value; + const shoot = async (name) => { + await sleep(250); + const { data } = await send('Page.captureScreenshot', { format: 'png' }); + await writeFile(join(frames, name), Buffer.from(data, 'base64')); + }; + const click = async (x, y) => { + for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased']) + await send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 }); + }; + + await send('Emulation.setDeviceMetricsOverride', { width: 1100, height: 760, deviceScaleFactor: 1, mobile: false }); + await send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: downloads }); + await send('Page.navigate', { url: 'http://127.0.0.1:4178/' }); + for (let i = 0; i < 100 && !(await evaluate('!!document.querySelector("#map canvas")')); i++) await sleep(100); + await sleep(1500); + + const layout = await evaluate(`(() => { + const box = (el) => { const r = el.getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; }; + return { canvas: box(document.querySelector('#map canvas')), button: box(document.querySelector('#export')), + status: box(document.querySelector('#status')) }; + })()`); + const [left, top, width, height] = layout.canvas; + const cell = (col, row) => [left + (col + 0.5) * width / 18, top + (row + 0.5) * height / 10]; + layout.clicks = [7, 8, 9, 10].map((col) => cell(col, 6)); + + await shoot('browser-0.png'); + for (const [i, [x, y]] of layout.clicks.entries()) { + await click(x, y); + await shoot(`browser-${i + 1}.png`); + } + const [bx, by, bw, bh] = layout.button; + await click(bx + bw / 2, by + bh / 2); + await shoot('browser-export.png'); + for (let i = 0; i < 50 && !(await readdir(downloads)).includes('level-edited.tmj'); i++) await sleep(100); + await writeFile(join(frames, 'browser-layout.json'), JSON.stringify(layout, null, 2)); + console.log('captured', layout); + socket.close(); +} finally { + chrome.kill(); + server.kill(); + await sleep(500); + await rm(profile, { recursive: true, force: true }).catch(() => {}); +} diff --git a/assets/devto-roundtrip/capture-tiled.ps1 b/assets/devto-roundtrip/capture-tiled.ps1 new file mode 100644 index 0000000..38847a7 --- /dev/null +++ b/assets/devto-roundtrip/capture-tiled.ps1 @@ -0,0 +1,81 @@ +# Captures the real Tiled editor for the article GIF: level.tmj as authored, and +# the browser's exported level-edited.tmj with the Bridge layer selected, plain +# and with View > Highlight Current Layer. Tiled runs at 150 % on a 4K display +# (DISPLAY2 here), and captures are in physical pixels. +# Tiled's session file and registry preferences are backed up and restored. +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot -replace '\\', '/' +$frames = Join-Path $PSScriptRoot 'frames' +$tiled = 'C:\Program Files\Tiled\tiled.exe' +$session = Join-Path $env:APPDATA 'Tiled\default.tiled-session' +$backup = Join-Path $env:TEMP 'roundtrip-tiled-backup' + +Add-Type -AssemblyName System.Drawing, System.Windows.Forms +Add-Type @' +using System; using System.Runtime.InteropServices; +public static class Win { + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int w, int hh, uint f); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint f); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern IntPtr SetProcessDpiAwarenessContext(IntPtr v); + public struct RECT { public int L, T, R, B; } +} +'@ +[Win]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null # per-monitor v2: physical pixels +$prefs = 'HKCU:\Software\mapeditor.org\Tiled\Interface' + +function Capture([IntPtr]$hwnd, [string]$name) { + $r = New-Object Win+RECT + [Win]::GetWindowRect($hwnd, [ref]$r) | Out-Null + $bmp = New-Object System.Drawing.Bitmap ($r.R - $r.L), ($r.B - $r.T) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $dc = $g.GetHdc() + [Win]::PrintWindow($hwnd, $dc, 2) | Out-Null + $g.ReleaseHdc($dc); $g.Dispose() + $bmp.Save((Join-Path $frames $name), [System.Drawing.Imaging.ImageFormat]::Png) + $bmp.Dispose() +} + +function Open-Tiled([string]$file, [bool]$highlight) { + Set-ItemProperty $prefs HighlightCurrentLayer ($highlight ? 'true' : 'false') + # Bridge is layer index 2 (bottom-up). Tiled zooms in physical pixels: 0.375 shows 864x480. + $state = @{ scale = 0.375; selectedLayer = 2; viewCenter = @{ x = 1152; y = 640 } } + @{ + activeFile = "$root/$file"; expandedProjectPaths = @(); project = '' + fileStates = @{ "$root/$file" = $state } + openFiles = @("$root/$file"); recentFiles = @("$root/$file") + } | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 $session + $p = Start-Process $tiled -PassThru + for ($i = 0; $i -lt 100 -and $p.MainWindowHandle -eq 0; $i++) { Start-Sleep -Milliseconds 200; $p.Refresh() } + $h = $p.MainWindowHandle + [Win]::ShowWindow($h, 9) | Out-Null # SW_RESTORE, in case it was saved maximized + [Win]::SetWindowPos($h, [IntPtr]::Zero, 4040, 200, 2000, 1150, 0x40) | Out-Null + (New-Object -ComObject WScript.Shell).AppActivate($p.Id) | Out-Null + [Win]::SetForegroundWindow($h) | Out-Null + Start-Sleep -Seconds 3 + return $p +} + +New-Item -ItemType Directory -Force $backup | Out-Null +Copy-Item $session (Join-Path $backup 'default.tiled-session') -Force +reg export 'HKCU\Software\mapeditor.org\Tiled' (Join-Path $backup 'tiled.reg') /y | Out-Null +try { + # The first launch lays out the view before the window is resized; it only + # stores the window geometry for the launches that follow. + foreach ($shot in @( + @('level.tmj', $false, $null), + @('level.tmj', $false, 'tiled-before.png'), + @('level-edited.tmj', $false, 'tiled-after.png'), + @('level-edited.tmj', $true, 'tiled-after-highlight.png'))) { + $p = Open-Tiled $shot[0] $shot[1] + if ($shot[2]) { Capture $p.MainWindowHandle $shot[2] } + $p.CloseMainWindow() | Out-Null; $p.WaitForExit(10000) | Out-Null + } +} finally { + Get-Process tiled -ErrorAction SilentlyContinue | Stop-Process -Force + Copy-Item (Join-Path $backup 'default.tiled-session') $session -Force + reg delete 'HKCU\Software\mapeditor.org\Tiled' /f | Out-Null + reg import (Join-Path $backup 'tiled.reg') 2>$null +} diff --git a/assets/devto-roundtrip/compose.py b/assets/devto-roundtrip/compose.py new file mode 100644 index 0000000..a5595df --- /dev/null +++ b/assets/devto-roundtrip/compose.py @@ -0,0 +1,298 @@ +"""Compose the article GIF from real captures (capture-browser.mjs, capture-tiled.ps1). + +The map sits at the same pixel position in the Tiled and browser captures, so the +crossfades change only the tool around it. Drawn in post: header, labels, code +line, pointer, click ripples, the file chip, and the bridge outline. +""" +from pathlib import Path +import json +import re +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +ROOT = Path(__file__).resolve().parent +FR = ROOT / 'frames' +W, H = 1000, 792 +BG, PANEL, LINE = '#0e1723', '#152131', '#2a3a4e' +GREEN, TEXT, DIM, FAINT = '#7de2b1', '#eef4fa', '#a1b3c8', '#5d6f86' +WIN_X, BAR_Y, STAGE_Y, STAGE_W, STAGE_H = 35, 64, 98, 930, 626 +F = 'C:/Windows/Fonts/' +ui = ImageFont.truetype(F + 'segoeui.ttf', 17) +ui_bold = ImageFont.truetype(F + 'seguisb.ttf', 18) +brand = ImageFont.truetype(F + 'seguisb.ttf', 19) +mono = ImageFont.truetype(F + 'consola.ttf', 22) +mono_small = ImageFont.truetype(F + 'consola.ttf', 16) + +# --- stage sources: identical map position (33, 60) in every capture -------- +def tiled_stage(name): + shot = Image.open(FR / name).convert('RGB') + stage = Image.new('RGB', (STAGE_W, STAGE_H), PANEL) + stage.paste(shot.crop((10, 106, 940, 150)), (0, 0)) # real toolbar + stage.paste(shot.crop((498, 363, 1428, 945)), (0, 44)) # map view + layers = shot.crop((1456, 152, 1990, 252)) # Layers dock + shadow = Image.new('L', (STAGE_W, STAGE_H), 0) + ImageDraw.Draw(shadow).rounded_rectangle((372, 62, 372 + 534 + 12, 62 + 100 + 12), 8, fill=110) + stage.paste(Image.new('RGB', stage.size, '#000'), (0, 0), shadow.filter(ImageFilter.GaussianBlur(8))) + stage.paste(layers, (380, 64)) + ImageDraw.Draw(stage).rectangle((379, 63, 380 + 534, 64 + 100), outline='#4a5b70') + return stage + +def browser_stage(name): + stage = Image.open(FR / name).convert('RGB').crop((85, 92, 85 + STAGE_W, 92 + STAGE_H)) + ImageDraw.Draw(stage).rectangle((0, 0, STAGE_W, 50), fill='#101b29') # crop out the page heading + return stage + +layout = json.loads((FR / 'browser-layout.json').read_text()) +def to_frame(x, y): # page coordinates -> GIF coordinates + return x - 85 + WIN_X, y - 92 + STAGE_Y +CLICKS = [to_frame(*c) for c in layout['clicks']] +bx, by, bw, bh = layout['button'] +BUTTON = to_frame(bx + bw * 0.55, by + bh * 0.6) +BRIDGE = (WIN_X + 33 + 7 * 48, STAGE_Y + 60 + 6 * 48, WIN_X + 33 + 11 * 48, STAGE_Y + 60 + 7 * 48) + +STAGES = {n: tiled_stage(n + '.png') for n in ('tiled-before', 'tiled-after', 'tiled-after-highlight')} +STAGES |= {n: browser_stage(n + '.png') for n in + ('browser-0', 'browser-1', 'browser-2', 'browser-3', 'browser-4', 'browser-export')} +LABELS = { + 'tiled-before': ('Tiled', 'level.tmj', 'map editor'), + 'tiled-after': ('Tiled', 'level-edited.tmj', 'exported from the browser'), + 'tiled-after-highlight': ('Tiled', 'level-edited.tmj', 'View › Highlight Current Layer'), +} +for n in STAGES: + LABELS.setdefault(n, ('Browser', 'PixiJS v8', 'localhost:4178')) + +# --- drawing --------------------------------------------------------------- +CHIPS = ['Tiled', 'PixiJS', 'Tiled'] +def chip_boxes(): + boxes, x = [], WIN_X + probe = ImageDraw.Draw(Image.new('RGB', (1, 1))) + for i, label in enumerate(CHIPS): + w = int(probe.textlength(label, font=ui_bold)) + 34 + boxes.append((x, 16, x + w, 48)) + x += w + (118 if i < 2 else 0) + return boxes +CHIP_BOXES = chip_boxes() +ARROW_LABELS = ['load', 'export'] + +def header(d, active, fills): + for i, (label, box) in enumerate(zip(CHIPS, CHIP_BOXES)): + if i < 2: # arrow to the next chip, filled by progress + x0, x1, y = box[2] + 10, CHIP_BOXES[i + 1][0] - 14, 32 + d.line((x0, y, x1, y), fill=LINE, width=2) + if fills[i] > 0: + d.line((x0, y, x0 + (x1 - x0) * fills[i], y), fill=GREEN, width=2) + d.polygon([(x1 + 8, y), (x1, y - 5), (x1, y + 5)], fill=GREEN if fills[i] >= 1 else LINE) + tw = d.textlength(ARROW_LABELS[i], font=mono_small) + d.text(((x0 + x1 - tw) / 2, 8), ARROW_LABELS[i], font=mono_small, + fill=GREEN if fills[i] > 0 else FAINT) + on, done = i == active, i < active + d.rounded_rectangle(box, 16, fill=GREEN if on else PANEL, outline=GREEN if (on or done) else LINE, width=2) + tw = d.textlength(label, font=ui_bold) + d.text(((box[0] + box[2] - tw) / 2, 19), label, font=ui_bold, + fill='#0f2a20' if on else (TEXT if done else DIM)) + tw = d.textlength('pixi-tiledmap', font=brand) + d.text((W - WIN_X - tw, 20), 'pixi-tiledmap', font=brand, fill=GREEN) + +def window(frame, d, stage_name): + d.rounded_rectangle((WIN_X - 1, BAR_Y - 1, WIN_X + STAGE_W, STAGE_Y + STAGE_H), 10, fill=PANEL, outline=LINE) + tool, file, note = LABELS[stage_name] + d.rounded_rectangle((WIN_X + 12, BAR_Y + 10, WIN_X + 24, BAR_Y + 22), 3, + fill=GREEN if tool == 'Browser' else '#89bdf8') + d.text((WIN_X + 34, BAR_Y + 5), tool, font=ui_bold, fill=TEXT) + x = WIN_X + 40 + d.textlength(tool, font=ui_bold) + d.text((x, BAR_Y + 6), '· ' + file, font=ui, fill=DIM) + tw = d.textlength(note, font=ui) + d.text((WIN_X + STAGE_W - 14 - tw, BAR_Y + 6), note, font=ui, fill=GREEN if '›' in note else FAINT) + frame.paste(STAGES[stage_name], (WIN_X, STAGE_Y)) + +TOKEN = re.compile(r"//.*|'[^']*'|\b\d+\b|[A-Za-z_$][\w$]*|\s+|.") +KEYWORDS = {'const', 'await'} +def code_line(d, code, flash=None): + x, y = WIN_X + 2, 744 + for token in TOKEN.findall(code): + color = TEXT + if token.startswith('//'): color = FAINT + elif token.startswith("'"): color = '#a8d5a2' + elif token in KEYWORDS: color = '#c3a6ef' + elif token in {'loadTiledMapAsset', 'setTile', 'exportMap'}: color = '#e9ca83' + elif token == '→': color = GREEN + elif token.isdigit(): color = '#dfb78b' + w = d.textlength(token, font=mono) + if flash is not None and token == flash[0] and flash[1] > 0: + a = flash[1] + d.rounded_rectangle((x - 4, y - 3, x + w + 4, y + 25), 5, fill=_mix(BG, GREEN, 0.35 * a)) + color = _mix('#dfb78b', '#ffffff', a) + flash = (None, 0) + d.text((x, y), token, font=mono, fill=color) + x += w + +def _mix(a, b, t): + a, b = [tuple(int(c[i:i + 2], 16) for i in (1, 3, 5)) for c in (a, b)] + return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3)) + +POINTER = [(0, 0), (0, 22), (5, 17), (9, 26), (13, 24), (9, 16), (16, 16)] +def pointer(d, x, y, pressed=0.0): + s = 1 - 0.12 * pressed + pts = [(x + px * s, y + py * s) for px, py in POINTER] + d.polygon([(px + 2, py + 2) for px, py in pts], fill='#070b10') + d.polygon(pts, fill='#ffffff', outline='#11161d', width=2) + +def ripple(frame, x, y, t): + over = Image.new('RGBA', frame.size, (0, 0, 0, 0)) + r = 8 + 22 * t + ImageDraw.Draw(over).ellipse((x - r, y - r, x + r, y + r), outline=(125, 226, 177, int(255 * (1 - t))), width=3) + frame.alpha_composite(over) + +def file_chip(frame, x, y, alpha=1.0): + over = Image.new('RGBA', frame.size, (0, 0, 0, 0)) + d = ImageDraw.Draw(over) + label = 'level-edited.tmj' + w = d.textlength(label, font=mono_small) + 40 + a = int(255 * alpha) + d.rounded_rectangle((x - w / 2, y - 15, x + w / 2, y + 15), 8, fill=(22, 36, 52, a), outline=(125, 226, 177, a), width=2) + fx, fy = x - w / 2 + 12, y - 8 # document glyph + d.polygon([(fx, fy), (fx + 9, fy), (fx + 13, fy + 4), (fx + 13, fy + 16), (fx, fy + 16)], fill=(125, 226, 177, a)) + d.text((x - w / 2 + 31, y - 9), label, font=mono_small, fill=(238, 244, 250, a)) + frame.alpha_composite(over) + +def bridge_outline(frame, t): + if t <= 0: return + over = Image.new('RGBA', frame.size, (0, 0, 0, 0)) + x0, y0, x1, y1 = BRIDGE + g = 3 + 5 * (1 - t) + ImageDraw.Draw(over).rounded_rectangle((x0 - g, y0 - g, x1 + g, y1 + g), 6, + outline=(125, 226, 177, int(255 * t)), width=3) + frame.alpha_composite(over) + +def render(s): + frame = Image.new('RGBA', (W, H), BG) + d = ImageDraw.Draw(frame) + header(d, s['active'], s.get('fills', (0, 0))) + window(frame, d, s['stage']) + d = ImageDraw.Draw(frame) + code_line(d, s['code'], s.get('flash')) + bridge_outline(frame, s.get('outline', 0)) + if 'ripple' in s: ripple(frame, *s['ripple']) + if 'chip' in s: file_chip(frame, *s['chip']) + if 'pointer' in s: pointer(ImageDraw.Draw(frame), *s['pointer']) + return frame.convert('RGB') + +# --- timeline -------------------------------------------------------------- +ease = lambda t: t * t * (3 - 2 * t) +lerp = lambda a, b, t: (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) +frames, durations = [], [] +def hold(s, ms): frames.append(render(s)); durations.append(ms) +def fade(a, b, n=6, ms=60, **tween): + for i in range(1, n + 1): + t = ease(i / (n + 1)) + mid = {k: tuple(x0 + (x1 - x0) * t for x0, x1 in zip(*v)) for k, v in tween.items()} + fa, fb = render(a | mid), render(b | mid) + # Only the stage blends; header, labels, and code switch at the midpoint. + frame = (fa if t < 0.5 else fb).copy() + box = (WIN_X, STAGE_Y, WIN_X + STAGE_W, STAGE_Y + STAGE_H) + frame.paste(Image.blend(fa.crop(box), fb.crop(box), t), box[:2]) + frames.append(frame); durations.append(ms) + +CODE_START = "// level.tmj, authored in Tiled. Its Bridge layer is empty." +CODE_LOAD = "const { container: map } = await loadTiledMapAsset('./level.tmj')" +CODE_SET = "map.setTile('Bridge', {col}, 6, {{ tileset: 'platformer', tileId: 4 }})" +CODE_EXPORT = "exportMap(map.mapData) → level-edited.tmj" +CODE_END = "// Back in Tiled: the four tiles are on the Bridge layer." + +start = {'stage': 'tiled-before', 'active': 0, 'code': CODE_START} +hold(start, 2000) +loaded = {'stage': 'browser-0', 'active': 1, 'fills': (1, 0), 'code': CODE_LOAD} +fade(start, loaded, fills=((0, 0), (1, 0))) +hold(loaded, 1300) + +pos = to_frame(760, 520) +for i, target in enumerate(CLICKS): + col = 7 + i + base = {'active': 1, 'fills': (1, 0), 'code': CODE_SET.format(col=col - 1 if i else 7)} + before = f'browser-{i}' + steps = 12 if i == 0 else 6 + for k in range(1, steps + 1): + hold(base | {'stage': before, 'pointer': (*lerp(pos, target, ease(k / steps)), 0)}, 40) + pos = target + for k, t in enumerate((0.0, 0.33, 0.66, 1.0)): + hold(base | {'stage': f'browser-{i + 1}', 'code': CODE_SET.format(col=col), + 'flash': (str(col), 1 - t * 0.6), 'ripple': (*target, t), + 'pointer': (*target, 1 if k < 2 else 0)}, 50) + hold(base | {'stage': f'browser-{i + 1}', 'code': CODE_SET.format(col=col), + 'flash': (str(col), 0.3), 'pointer': (*target, 0)}, 260) +edited = {'stage': 'browser-4', 'active': 1, 'fills': (1, 0), 'code': CODE_SET.format(col=10)} +hold(edited | {'pointer': (*pos, 0)}, 500) + +exporting = {'stage': 'browser-4', 'active': 1, 'fills': (1, 0), 'code': CODE_EXPORT} +for k in range(1, 11): + hold(exporting | {'pointer': (*lerp(pos, BUTTON, ease(k / 10)), 0)}, 40) +for k, t in enumerate((0.0, 0.33, 0.66, 1.0)): + hold(exporting | {'stage': 'browser-export', 'ripple': (*BUTTON, t), 'pointer': (*BUTTON, 1 if k < 2 else 0)}, 50) +exported = exporting | {'stage': 'browser-export', 'pointer': (*BUTTON, 0)} +hold(exported, 300) +dest = ((CHIP_BOXES[2][0] + CHIP_BOXES[2][2]) / 2, 32) +lift = (BUTTON[0] - 60, BUTTON[1] - 50) +for k in range(1, 15): + t = ease(k / 14) + p = lerp(lerp(lift, (lift[0], dest[1] + 40), t), lerp((lift[0], dest[1] + 40), dest, t), t) + hold(exported | {'fills': (1, t), 'chip': (*p, 1.0 if k < 12 else (14 - k) / 3 + 0.2)}, 45) +back = {'stage': 'tiled-after', 'active': 2, 'fills': (1, 1), 'code': CODE_END} +fade(exported | {'fills': (1, 1)}, back) +hold(back, 1000) +lit = back | {'stage': 'tiled-after-highlight'} +fade(back, lit, n=4, ms=70) +for k in range(1, 7): + hold(lit | {'outline': ease(k / 6)}, 45) +hold(lit | {'outline': 1}, 2800) +fade(lit | {'outline': 1}, start, n=6, ms=70, fills=((1, 1), (0, 0))) + +# --- encode --------------------------------------------------------------- +# One shared palette across all frames avoids palette flicker between scenes. +samples = [frames[i] for i in (0, 5, 8, 40, len(frames) // 2, len(frames) - 12, len(frames) - 3)] +sheet = Image.new('RGB', (W, H * len(samples))) +for i, f in enumerate(samples): sheet.paste(f, (0, H * i)) +palette = sheet.quantize(colors=255, method=Image.Quantize.MEDIANCUT, kmeans=2) +indexed = [f.quantize(palette=palette, dither=Image.Dither.NONE) for f in frames] +out = ROOT / 'pixi-tiledmap-roundtrip.gif' +indexed[0].save(out, save_all=True, append_images=indexed[1:], duration=durations, loop=0, optimize=False, disposal=1) +for name, index in (('sheet-start', 0), ('sheet-edit', 40), ('sheet-end', len(frames) - 8)): + frames[index].save(FR / f'{name}.png') +print(f'{out.name}: {len(frames)} frames, {sum(durations) / 1000:.1f}s, ' + f'{out.stat().st_size / 1024:.0f} KiB, {W}x{H}') + +# --- dev.to cover (1000x420): the same map, half Tiled, half PixiJS ---------- +def cover(): + cw, ch = 1000, 420 + img = Image.new('RGBA', (cw, ch), BG) + d = ImageDraw.Draw(img) + d.text((56, 70), 'pixi-tiledmap', font=ImageFont.truetype(F + 'seguisb.ttf', 22), fill=GREEN) + title = ImageFont.truetype(F + 'seguisb.ttf', 50) + d.text((54, 112), 'Tiled → PixiJS', font=title, fill=TEXT) + d.text((54, 172), '→ Tiled', font=title, fill=TEXT) + sub = ImageFont.truetype(F + 'segoeui.ttf', 21) + for i, line in enumerate(('Load, edit, and export Tiled maps', 'at runtime, in TypeScript.')): + d.text((56, 258 + i * 30), line, font=sub, fill=DIM) + tiled = Image.open(FR / 'tiled-after.png').convert('RGB').crop((531, 379, 1395, 859)) + pixi = Image.open(FR / 'browser-4.png').convert('RGB').crop((118, 152, 982, 632)) + mw, mh = 540, 300 + tiled, pixi = (im.resize((mw, mh), Image.Resampling.LANCZOS) for im in (tiled, pixi)) + mask = Image.new('L', (mw, mh), 0) + ImageDraw.Draw(mask).polygon([(mw * 0.62, 0), (mw, 0), (mw, mh), (mw * 0.38, mh)], fill=255) + tiled.paste(pixi, (0, 0), mask) + x0, y0 = cw - mw - 44, (ch - mh) // 2 + shadow = Image.new('L', (cw, ch), 0) + ImageDraw.Draw(shadow).rounded_rectangle((x0 - 4, y0 + 6, x0 + mw + 4, y0 + mh + 14), 12, fill=150) + img.paste(Image.new('RGBA', (cw, ch), '#000'), (0, 0), shadow.filter(ImageFilter.GaussianBlur(14))) + round_mask = Image.new('L', (mw, mh), 0) + ImageDraw.Draw(round_mask).rounded_rectangle((0, 0, mw - 1, mh - 1), 10, fill=255) + img.paste(tiled, (x0, y0), round_mask) + d = ImageDraw.Draw(img) + d.line((x0 + mw * 0.62, y0, x0 + mw * 0.38, y0 + mh), fill=GREEN, width=3) + for label, (lx, ly), anchor in (('Tiled', (x0 + 14, y0 + 12), 'la'), ('PixiJS', (x0 + mw - 14, y0 + 12), 'ra')): + tw = d.textlength(label, font=ui_bold) + bx0 = lx if anchor == 'la' else lx - tw - 20 + d.rounded_rectangle((bx0, ly, bx0 + tw + 20, ly + 30), 15, fill=BG) + d.text((bx0 + 10, ly + 3), label, font=ui_bold, fill=GREEN) + img.convert('RGB').save(ROOT / 'devto-cover.png') + print('devto-cover.png: 1000x420') + +cover() diff --git a/assets/devto-roundtrip/devto-article.md b/assets/devto-roundtrip/devto-article.md new file mode 100644 index 0000000..542c945 --- /dev/null +++ b/assets/devto-roundtrip/devto-article.md @@ -0,0 +1,129 @@ +--- +title: "From Tiled to PixiJS and back: editable maps in TypeScript" +published: false +description: "Load a Tiled map in PixiJS, edit its tiles at runtime, and export the result to continue working in Tiled." +tags: typescript, gamedev, javascript, opensource +cover_image: devto-cover.png +--- + + + +Open a map in Tiled. Build a bridge in the browser. Open the changed map in Tiled again. + + + +I'm working on [pixi-tiledmap](https://github.com/riebel/pixi-tiledmap), a TypeScript library for loading, rendering, editing, and exporting Tiled maps with PixiJS v8. This post follows one small map through that whole loop. Every frame in the GIF comes from the real editor or the real demo. Only the pointer and the labels were added afterwards. + +The map has two islands, four coins, and a missing bridge. It has three tile layers: `Terrain`, `Details`, and an empty `Bridge` layer. Keeping the edit on its own layer makes it easy to find after the export. + +## Load the map + +Install the library and PixiJS: + +```sh +npm install pixi-tiledmap pixi.js +``` + +The following TypeScript runs in a browser project with a bundler. Serve `level.tmj` and its tileset image as static assets, and keep their relative paths. + +```ts +import { Application } from 'pixi.js'; +import { loadTiledMapAsset } from 'pixi-tiledmap'; + +const app = new Application(); +await app.init({ + width: 864, + height: 480, + background: '#b8e1eb', +}); +document.body.appendChild(app.canvas); + +const { container: map } = await loadTiledMapAsset('./level.tmj'); + +// This example uses 128 px tiles, displayed at 48 px. +map.scale.set(0.375); +app.stage.addChild(map); +``` + +`loadTiledMapAsset` parses the map, resolves external tilesets and templates, loads the textures, and builds the PixiJS container. It accepts both `.tmj` and `.tmx` files. + +## Edit tiles at runtime + +The bridge takes four clicks. Each click calls `setTile` with a layer name, a column and row, and a tile from a named tileset: + +```ts +map.setTile('Bridge', 7, 6, { + tileset: 'platformer', + tileId: 4, +}); +``` + +The other three tiles go at columns 8, 9, and 10 of the same row. Coordinates and the tileset-local `tileId` are zero-based, and the library turns the local ID into Tiled's global ID for you. + +`setTile` updates the rendered layer and the map data behind it. The export in the next step depends on that. + +## Export back to Tiled + +Add an export button: + +```html + +``` + +Then serialize the current map and download it as Tiled JSON: + +```ts +import { exportMap } from 'pixi-tiledmap'; + +document.querySelector('#export')!.addEventListener('click', () => { + const json = JSON.stringify(exportMap(map.mapData), null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = 'level-edited.tmj'; + link.click(); + + setTimeout(() => URL.revokeObjectURL(url), 1000); +}); +``` + +Put the downloaded file next to the original map and open it in Tiled. The four new tiles are on the `Bridge` layer, ready for more editing. The end of the GIF uses Tiled's *View › Highlight Current Layer*: with `Bridge` selected, everything else is dimmed and only the tiles added in the browser stay bright. + +The `.tmj` contains the map data. It does not bundle the tileset images, so `platformer.png` has to sit next to the exported file, and maps with external tilesets also need those files at the paths they reference. + +## Try it + +The complete demo is in the repository as [`examples/tiled-roundtrip`](https://github.com/riebel/pixi-tiledmap/tree/master/examples/tiled-roundtrip), including the map and the CC0 tileset: + +{% embed https://stackblitz.com/github/riebel/pixi-tiledmap/tree/master/examples/tiled-roundtrip?file=src/main.ts %} + +If the embedded preview blocks the download, open the preview in its own tab. + +## What's under the hood + +Three decisions make this work. + +**Rendering and export share one map model.** `map.mapData` is the `ResolvedMap` that the renderer was built from: external references resolved, defaults filled in, tile references decoded. `setTile` writes into it, and `exportMap` reads from it, so there is no second copy to keep in sync. Parsing an exported map gives back a map that is deep-equal to the one you exported. Maps generated with `createMap` use the same model, so they can go through the same render, edit, and export steps. + +**An edit writes one quad, not a layer.** Static tiles are batched into a few PixiJS meshes per layer rather than one sprite per tile. Each bridge click fills an empty cell on a static orthogonal layer, so the new tile becomes one more quad in the layer's packed mesh, and the layer is never rebuilt. Clearing a tile gives its slot back for the next insert. The frame of the [showcase](https://pixi-tiledmap-showcase.vercel.app/) in the README shows about 13,000 `setTile` calls per second on an animated 7,891-quad layer at 144 fps. A layer is rebuilt only when draw order depends on the edit, for example when inserting on isometric or hexagonal maps, or when a tile switches between static and animated. The [runtime editing docs](https://github.com/riebel/pixi-tiledmap#runtime-editing-and-procedural-maps) list every case. + +**The export keeps what Tiled cares about.** A tileset that came from an external file is written back as a reference to that file, not embedded. Each tile layer keeps its CSV or base64 encoding. `nextlayerid` and `nextobjectid` are only ever raised, so IDs of layers and objects you deleted in Tiled are not handed out again. + +## Limits + +- Runtime per-tile alpha has no field in a Tiled tile layer, so it is not exported. +- Synchronous `exportMap` writes gzip or zlib layers as uncompressed base64. Use `exportMapAsync` to keep the compression. +- The export is always Tiled JSON, even when the source map was TMX. +- Game state such as inventory or physics stays in your application. The export covers the map. + +## Where this helps + +You can build a level editor into a PixiJS game, save a generated layout for hand-polishing in Tiled, or take a map that changed during play back into the editor to inspect it. + +pixi-tiledmap is MIT-licensed and listed in [Tiled's Libraries and Frameworks documentation](https://doc.mapeditor.org/en/stable/reference/support-for-tmx-maps/#html5-multiple-engines). The [repository](https://github.com/riebel/pixi-tiledmap) has the API documentation, the supported features, and the benchmarks. + +If you use Tiled with PixiJS, what would you want to change in the browser and bring back into the editor? + +*Demo artwork: Kenney New Platformer Pack, CC0.* diff --git a/assets/devto-roundtrip/devto-cover.png b/assets/devto-roundtrip/devto-cover.png new file mode 100644 index 0000000..5fd3ba9 Binary files /dev/null and b/assets/devto-roundtrip/devto-cover.png differ diff --git a/assets/devto-roundtrip/index.html b/assets/devto-roundtrip/index.html new file mode 100644 index 0000000..fbf10f8 --- /dev/null +++ b/assets/devto-roundtrip/index.html @@ -0,0 +1,5 @@ + +