Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions assets/devto-roundtrip/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
bundle.js
*.js.map
*.js
frames/
5 changes: 5 additions & 0 deletions assets/devto-roundtrip/KENNEY-LICENSE.txt
Original file line number Diff line number Diff line change
@@ -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/
23 changes: 23 additions & 0 deletions assets/devto-roundtrip/README.md
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 94 additions & 0 deletions assets/devto-roundtrip/capture-browser.mjs
Original file line number Diff line number Diff line change
@@ -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(() => {});
}
81 changes: 81 additions & 0 deletions assets/devto-roundtrip/capture-tiled.ps1
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading