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
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ Buildings and resources count as walls (they block the game's pathing too), and
ask for the ground as a small, medium or large unit sees it, since a Siege Tank does not
fit where a Zergling does.

All of that is an estimate worked out from the minitiles. In the editor at scmjs.dev, the
panel can also have **the game itself** walk the map (see below): the regions the game
plans ground routes over, and real units walked between the start locations by the
game's own pathfinding, with what went wrong on the way.

## Install

In scmJS: **Plugins ▸ Manage Plugins…**, paste
Expand Down Expand Up @@ -76,16 +81,61 @@ Options:
- **Buildings and resources block the way** treats every building and mineral field or
geyser as a wall, as the game does. Untick it to see the terrain alone.

## The game's own pathing

Since 1.3.0. Only in the editor at scmjs.dev: it needs the
[scm-sim](https://github.com/scm-js/plugin-scm-sim) plugin, version 0.7.0 or later, and
the game engine served beside the editor. A copy of the game from StarEdit's download is
enough. Elsewhere, for example in the desktop app, the section does not appear.

**Walk the map**, in the panel's *The game's own pathing* section, starts the map in the
game, out of sight, and walks the ticked units (Probe, Zergling, Dragoon, Siege Tank and
Ultralisk to begin with) from each start location to every other one. Each start
location walked from is a game of its own and takes a few seconds: a 4-player map takes
about ten seconds, an 8-player map about forty. Nobody plays and the map is not changed.
The game is started on a copy that has a neutral Nexus on every start location and none of
the map's triggers.

The overlay then shows *The game's regions and walks*:

- **Regions**: one colour per region of the game's, darker at its edges. These are what
the game plans ground routes over. Pointing at the map names the region and its ground
group (ground of one group is joined).
- **Walks**: each unit's route in its own colour. A walk that went wrong ends in a red
cross.

*What the game found* lists:

- A walk that **stops short**. The game can find no way all the way, or gives up
looking on a long route. On Bridge to Bridge '98, every unit sent between two of the
start locations stops about 150 tiles away.
- A walk that **gets stuck**, or is **still walking** after seven minutes.
- Two start locations the game finds **no ground between**, and whether Walkability's
estimate disagrees.
- A route the game walks **much longer than the shortest ground route** (30 % and ten
tiles more).
- A unit the game **could not place** near a start location (its spot was a cliff edge
or too tight for it), so it walked nothing from there.

Click a row to go to it. *Both ways* walks each pair in both directions, since a route is
not always the same backwards. **Walk between two points** asks for two clicks and walks
the ticked units between them.

The details panel lists every walk with its time at Fastest. *Copy report* includes them.
An edit to the map marks the walks out of date; walk the map again to bring them up to date.

## Layout

| | |
| --- | --- |
| `plugin.json` | the manifest the editor reads (name, version, `entry`, `icon`, the API version it needs) |
| `plugin.ts` | `activate(api)`: building the grid from the open map, the overlay bitmaps, the overlay (`api.ui.overlay`) and the two panels |
| `game.ts` | the game's own pathing: the walks matched against the estimate, the findings, the wording (pure, tested) |
| `vendor/scm-sim.d.ts` | a copy of the scm-sim plugin's service types (`contract.d.ts` at v0.7.0) |
| `analysis.ts` | the pure part: the minitile grid, clearance (an exact Euclidean distance transform), islands, the watershed into areas and chokes, seams, the start-to-start routes, the text report |
| `i18n.ts`, `ko.ts` | the plugin's words in the editor's language: `t()` / `msg()` and the Korean catalogue |
| `dist/plugin.js` | the bundle the editor loads; `npm run build` writes it, CI commits it |
| `tests/` | vitest over `analysis.ts`, and a check that `ko.ts` has every string the plugin shows |
| `tests/` | vitest over `analysis.ts` and `game.ts`, and a check that `ko.ts` has every string the plugin shows |

Types come from [`@scm-js/plugin-api`](https://github.com/scm-js/plugin-api), a devDependency
generated from the editor's own `src/plugins/api.ts`; `npm update @scm-js/plugin-api` takes the
Expand Down
543 changes: 534 additions & 9 deletions dist/plugin.js

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* The game's own pathing, as the scm-sim plugin's engine answers it (`vendor/scm-sim.d.ts`,
* `pathing()`): what Walkability makes of the answer. The engine walks real units between the
* start locations with the game's own code; here the walks are matched with Walkability's
* own estimate of each route and turned into findings for the panels and the report. Pure,
* with its own tests; the editor side is in `plugin.ts`.
*/
import type { PathingResult, PathingWalk } from "./vendor/scm-sim";
import type { Analysis } from "./analysis";
import { t } from "./i18n";

/** The units the game can walk here (units.dat ids): Probe, Zergling, Dragoon, Siege Tank, Ultralisk. Their names come from the editor. */
export const GAME_UNITS: readonly number[] = [64, 37, 66, 5, 39];

/** Frames a second at Fastest, the speed games are played at (42 ms a frame). */
export const FASTEST = 1000 / 42;

/** A walk's time at Fastest, in seconds with one decimal. */
export function seconds(frames: number): string {
return (frames / FASTEST).toFixed(1);
}

/** A route the game walks at least this much longer than Walkability's shortest ground route is called a detour. */
export const DETOUR_RATIO = 1.3;
/** …and only when the difference is at least this many pixels (ten tiles): short routes vary by more than a ratio says. */
export const DETOUR_MIN_PX = 320;

export type FindingKind = "stopped" | "stuck" | "limit" | "detour" | "apart";

/** Something about a walk worth a map maker's attention. `walk` indexes `PathingResult.walks`. */
export interface Finding {
kind: FindingKind;
walk: number;
/** For a detour: the pixels the game walked over the estimate's shortest ground route. */
extra?: number;
/** For `apart`: whether Walkability's estimate found a ground route all the same (the two disagree). */
estimateHasRoute?: boolean;
}

/** The estimate's pair between the two start locations of player slots `a` and `b`, either way round. */
export function estimatePair(a: Analysis, ownerA: number, ownerB: number) {
return a.pairs.find((p) => {
const x = a.starts[p.a].owner, y = a.starts[p.b].owner;
return (x === ownerA && y === ownerB) || (x === ownerB && y === ownerA);
}) ?? null;
}

/**
* The findings, worst first: walks that stopped short, got stuck or ran out of time, starts
* the game finds no ground between, and routes the game walks much longer than the shortest
* ground route Walkability estimates (`analysis`, when there is one).
*/
export function findings(result: PathingResult, analysis: Analysis | null): Finding[] {
const out: Finding[] = [];
result.walks.forEach((w, i) => {
if (w.outcome === "stopped" || w.outcome === "stuck" || w.outcome === "limit") { out.push({ kind: w.outcome, walk: i }); return; }
if (w.outcome === "apart") {
const pair = analysis && w.fromStart !== undefined && w.toStart !== undefined ? estimatePair(analysis, w.fromStart, w.toStart) : null;
out.push({ kind: "apart", walk: i, estimateHasRoute: pair ? pair.ground !== null : undefined });
return;
}
if (w.outcome !== "arrived" || !analysis || w.fromStart === undefined || w.toStart === undefined) return;
const pair = estimatePair(analysis, w.fromStart, w.toStart);
if (!pair || pair.ground === null) return;
const extra = w.travelled - pair.ground;
if (w.travelled >= pair.ground * DETOUR_RATIO && extra >= DETOUR_MIN_PX) out.push({ kind: "detour", walk: i, extra });
});
const rank: Record<FindingKind, number> = { stuck: 0, stopped: 1, limit: 2, apart: 3, detour: 4 };
return out.sort((x, y) => rank[x.kind] - rank[y.kind] || x.walk - y.walk);
}

/** How many walks ended each way. */
export function outcomes(walks: readonly PathingWalk[]): Record<string, number> {
const out: Record<string, number> = {};
for (const w of walks) out[w.outcome] = (out[w.outcome] ?? 0) + 1;
return out;
}

/** A walk's outcome in words, for a list's hint. */
export function describeWalk(w: PathingWalk): string {
const tilesOff = Math.round(w.distance / 32);
switch (w.outcome) {
case "arrived": return t("{s} s", { s: seconds(w.frames) });
case "stopped": return t("stopped {n} tiles short", { n: tilesOff });
case "stuck": return t("stuck {n} tiles short", { n: tilesOff });
case "limit": return t("still walking after {s} s", { s: seconds(w.frames) });
case "apart": return t("no ground between");
case "unmoved": return t("did not move");
}
}

/** Where to look at a walk: where it ended when it went wrong, else its middle. */
export function walkFocus(w: PathingWalk): { x: number; y: number } {
if (w.outcome !== "arrived" && w.x.length) return { x: w.x[w.x.length - 1], y: w.y[w.y.length - 1] };
const mid = Math.floor(w.x.length / 2);
return w.x.length ? { x: w.x[mid], y: w.y[mid] } : w.from;
}

/**
* The units the game did not place: the engine stands the test units near each point walked
* from, and the game leaves out a unit whose spot it would not take (a cliff edge, too tight
* for its size), so it has no walks from there. `froms` are the start locations walked from
* (their player slots), or `undefined` for the person's own point.
*/
export function unplaced(walks: readonly PathingWalk[], asked: readonly number[], froms: readonly (number | undefined)[]): { unit: number; fromStart?: number }[] {
const out: { unit: number; fromStart?: number }[] = [];
for (const from of froms) {
const there = new Set(walks.filter((w) => w.fromStart === from).map((w) => w.unit));
for (const unit of asked) if (!there.has(unit)) out.push(from === undefined ? { unit } : { unit, fromStart: from });
}
return out;
}

/** The start locations a walk of the map goes from: each but the last, or every one when walked both ways. */
export function walkedFrom(result: PathingResult, bothWays: boolean): number[] {
const slots = result.starts.map((s) => s.owner);
return bothWays ? slots : slots.slice(0, -1);
}
47 changes: 47 additions & 0 deletions ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,51 @@ export const KO: Record<string, string> = {
"{size} tiles, {chokes, plural, one {# choke} other {# chokes}}": "{size}타일, 길목 {chokes}개",
"{width} tiles wide at {x}, {y}": "{x}, {y}의 폭 {width}타일",
"{width} tiles wide at {x}, {y} between {a} and {b}": "{a}와(과) {b} 사이, {x}, {y}의 폭 {width}타일",
"The game's regions and walks": "게임의 지역과 이동",
"This needs version 0.7.0 or later of the scm-sim plugin.": "scm-sim 플러그인 0.7.0 이상이 필요합니다.",
"The engine's page cannot work out a map's pathing yet. Reload the editor and try again.": "엔진 페이지가 아직 맵의 경로를 계산할 수 없습니다. 편집기를 새로 고친 뒤 다시 시도하세요.",
"{x}, {y}": "{x}, {y}",
"{unit}: {from} → {to}": "{unit}: {from} → {to}",
"Click where the units start": "유닛이 출발할 곳을 클릭하세요",
"Click where they should walk to": "유닛이 걸어갈 곳을 클릭하세요",
"Walkability: the game walked {n, plural, one {# route} other {# routes}}, {arrived} arrived, {problems, plural, =0 {no problems} one {# problem} other {# problems}}": "이동 가능 지형: 게임이 경로 {n}개를 걸었습니다. {arrived}개 도착, {problems, plural, =0 {문제 없음} other {문제 #개}}",
"Walkability: the game's walk was stopped": "이동 가능 지형: 게임의 이동을 멈췄습니다",
"{unit} from {from} to {to} stops short": "{from}에서 {to|으로} 가는 {unit|이} 도중에 멈춥니다",
"{unit} from {from} to {to} gets stuck": "{from}에서 {to|으로} 가는 {unit|이} 갇힙니다",
"{unit} from {from} to {to} is still walking when the time runs out": "{from}에서 {to|으로} 가는 {unit|이} 시간이 다 될 때까지 걷고 있습니다",
"The game finds no ground between {from} and {to}, though the estimate does": "추정으로는 지상 경로가 있지만, 게임은 {from|과} {to} 사이에 지상 경로를 찾지 못합니다",
"No ground between {from} and {to}": "{from|과} {to} 사이에 지상 경로 없음",
"{unit} from {from} to {to} walks {n} tiles more than the shortest ground route": "{from}에서 {to|으로} 가는 {unit|이} 가장 짧은 지상 경로보다 {n}타일 더 걷습니다",
"The game's own pathing": "게임 자체의 경로 탐색",
"{n, plural, one {# walkable region} other {# walkable regions}} of {all}": "지역 {all}개 중 걸을 수 있는 지역 {n}개",
"What the game found": "게임이 찾은 것",
"the map was edited after the game walked it": "게임이 걸은 뒤 맵이 편집됨",
"the game's region {r}, ground group {g}": "게임의 지역 {r}, 지상 그룹 {g}",
"the game's region {r}, unwalkable": "게임의 지역 {r}, 걸을 수 없음",
"The game's engine is not available here.": "여기서는 게임 엔진을 쓸 수 없습니다.",
"Asking the game's engine…": "게임 엔진에 묻는 중…",
"The game itself walks units between the start locations, with its own regions and pathfinding. Each start location walked from takes a few seconds.": "게임이 직접 자체 지역과 경로 탐색으로 시작 위치 사이에 유닛을 걷게 합니다. 출발하는 시작 위치마다 몇 초가 걸립니다.",
"Both ways": "양방향",
"Walk from each start location to each other one, not each pair once": "각 쌍을 한 번씩이 아니라, 각 시작 위치에서 다른 모든 시작 위치로 걷습니다",
"Walk the map": "맵 걸어 보기",
"Walk between two points": "두 지점 사이 걷기",
"Stop": "멈춤",
"Walking {done} of {total}…": "{total}개 중 {done}개 걷는 중…",
"Starting the game…": "게임을 시작하는 중…",
"{n, plural, one {# walkable region} other {# walkable regions}}": "걸을 수 있는 지역 {n}개",
"{n, plural, one {# walk} other {# walks}}, {arrived} arrived": "이동 {n}개, {arrived}개 도착",
"Show": "보기",
"Show the game's regions and walks on the map": "게임의 지역과 이동을 맵에 표시합니다",
"Every walk arrived, by routes close to the shortest.": "모든 이동이 가장 짧은 경로에 가깝게 도착했습니다.",
"The game's walks": "게임의 이동",
"{n} tiles": "{n}타일",
"{s} s": "{s}초",
"stopped {n} tiles short": "{n}타일 앞에서 멈춤",
"stuck {n} tiles short": "{n}타일 앞에서 갇힘",
"still walking after {s} s": "{s}초 뒤에도 걷는 중",
"no ground between": "지상 경로 없음",
"did not move": "움직이지 않음",
"The game could not place a {unit} at the point picked": "게임이 고른 지점에 {unit|을} 놓지 못했습니다",
"The game could not place a {unit} near {from}'s start location, so it walked nothing from there": "게임이 {from}의 시작 위치 근처에 {unit|을} 놓지 못해 그곳에서는 아무것도 걷지 않았습니다",
"not placed": "놓지 못함",
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "scmjs-plugin-walkability",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"description": "Walkability — a plugin for the scmJS StarCraft map editor.",
"author": "Jeany",
Expand Down
4 changes: 2 additions & 2 deletions plugin.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "Walkability",
"id": "walkability",
"version": "1.2.0",
"description": "Reads the ground the way a unit does: islands, unreachable pockets, the areas a map divides into and the chokes between them with their widths, cliff seams with no ramp, and the ground distance between every pair of start locations, drawn over the map.",
"version": "1.3.0",
"description": "Reads the ground the way a unit does: islands, unreachable pockets, the areas a map divides into and the chokes between them with their widths, cliff seams with no ramp, and the ground distance between every pair of start locations, drawn over the map. At scmjs.dev it can also have the game itself walk the map, with the game's own regions and pathfinding.",
"author": "Jeany",
"homepage": "https://github.com/scm-js/plugin-walkability",
"entry": "plugin.ts",
Expand Down
Loading
Loading