evolutionary-arcade 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +1 -0
- package/dist/cli.js +287 -13
- package/package.json +1 -1
- package/skills/arcade-building-games/SKILL.md +15 -10
- package/skills/arcade-fork/SKILL.md +5 -3
- package/skills/arcade-onboarding/SKILL.md +5 -3
- package/skills/arcade-publishing/SKILL.md +41 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
- The publishing guide's vertical demo encode keeps the recording's shape (`scale=1080:-2`). The old `scale=1080:1920` squashed a 390x844 phone capture by about 18%.
|
|
6
|
+
|
|
7
|
+
## 0.4.0
|
|
8
|
+
|
|
9
|
+
- Games ship media for phones. arcade.json has `orientation` (`"landscape"`, `"portrait"`, or `"both"`; leaving it out means landscape) and `media_vertical`, a 9:16 set with the same fields as the landscape one (`thumbnail`, `screenshots`, `demo_video`, `preview_video`, `preview_gif`). The top-level media fields are the landscape set.
|
|
10
|
+
- A portrait or both game with touch must ship its own vertical media from real upright play: a thumbnail, at least one screenshot, and a vertical demo when it has a landscape demo.
|
|
11
|
+
- New `arcade media vertical`. For a landscape game it centers the landscape thumbnail, screenshots, and demo at full width over a blurred, darkened copy of themselves at 1080x1920, cuts a 480x854 hover preview and a 360x640 GIF, writes them to `media/vertical/`, and sets `media_vertical`. For a portrait or both game it cuts the vertical preview and GIF from the vertical demo. `arcade publish` makes a landscape game's set when it's missing, the way it makes the GIF.
|
|
12
|
+
- The dry run warns when game code calls `requestFullscreen` (or the webkit, moz, and ms versions): "the arcade's player owns fullscreen; remove your fullscreen button". It also flags a missing vertical thumbnail and a vertical set older than the landscape media.
|
|
13
|
+
- `arcade fork`, `blend`, `pull`, and `regen` carry `orientation` and `media_vertical` over.
|
|
14
|
+
- Skills: `arcade-publishing` covers deciding a game's orientation and capturing both sets in one pass (1280x720, plus 390x844 with touch emulation). `arcade-building-games` says "Don't build fullscreen. The platform owns it." `arcade-onboarding` and `arcade-fork` include the vertical set in their capture steps and time estimates.
|
|
15
|
+
|
|
3
16
|
## 0.3.0
|
|
4
17
|
|
|
5
18
|
- Every publish ends with a **Share it** block: the game's link, a video-description line, an X post draft under 280 characters, an X intent link, and the GIF to attach. A fork or blend's draft credits each parent's creator, by X handle when their profile links one. The agent drafts; the creator posts. The CLI never posts anywhere.
|
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ Everything you publish is public, including the source, and every game is open s
|
|
|
56
56
|
| `arcade publish [dir] [--dry-run] [--yes]` | Check everything, list every file that goes public, and publish. |
|
|
57
57
|
| `arcade share <slug> [--profile] [--badge] [--json]` | Reprint the Share block publish ends with: the link, a video-description line, an X post draft, and the GIF to attach. `--profile` shares your whole library; `--badge` prints the "Play on Evolutionary Arcade" badge as Markdown and HTML. You post them; the arcade never posts for you. |
|
|
58
58
|
| `arcade media preview [dir]` | Make the 12-second hover preview from your demo (needs ffmpeg). |
|
|
59
|
+
| `arcade media vertical [dir]` | Make the phone (9:16) media set: landscape games over a blurred fill, portrait games from their vertical demo (needs ffmpeg). |
|
|
59
60
|
| `arcade main <slug> <generation>` | (Owner) Pick the main generation for a version. |
|
|
60
61
|
| `arcade unpublish <slug>` / `arcade republish <slug>` | (Owner) Take your game down, or bring it back. |
|
|
61
62
|
| `arcade profile show` / `set` / `avatar` / `banner` | See and edit your public profile. |
|
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { dirname, isAbsolute, join } from "path";
|
|
|
13
13
|
// package.json
|
|
14
14
|
var package_default = {
|
|
15
15
|
name: "evolutionary-arcade",
|
|
16
|
-
version: "0.
|
|
16
|
+
version: "0.4.1",
|
|
17
17
|
description: "The arcade CLI for Evolutionary Arcade. Publish, update, regen, fork, and blend AI-made browser games with your own coding agent.",
|
|
18
18
|
type: "module",
|
|
19
19
|
license: "MIT",
|
|
@@ -20592,6 +20592,14 @@ var videoPath = relPath.refine(
|
|
|
20592
20592
|
(p) => ["mp4", "webm"].includes(ext(p)),
|
|
20593
20593
|
"Use an .mp4 or .webm video."
|
|
20594
20594
|
);
|
|
20595
|
+
var ORIENTATIONS = ["landscape", "portrait", "both"];
|
|
20596
|
+
var verticalMediaSchema = external_exports.object({
|
|
20597
|
+
thumbnail: imagePath.optional(),
|
|
20598
|
+
screenshots: external_exports.array(imagePath).max(12).default([]),
|
|
20599
|
+
demo_video: videoPath.optional(),
|
|
20600
|
+
preview_video: videoPath.optional(),
|
|
20601
|
+
preview_gif: gifPath.optional()
|
|
20602
|
+
});
|
|
20595
20603
|
var sourceSchema = external_exports.object({
|
|
20596
20604
|
repo: external_exports.string().regex(
|
|
20597
20605
|
/^https:\/\/github\.com\/[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/,
|
|
@@ -20638,6 +20646,8 @@ var arcadeSchema = external_exports.object({
|
|
|
20638
20646
|
// play: "someone remixed your game" emails and link previews. The CLI requires it whenever
|
|
20639
20647
|
// there's a demo.
|
|
20640
20648
|
preview_gif: gifPath.optional(),
|
|
20649
|
+
orientation: external_exports.enum(ORIENTATIONS).default("landscape"),
|
|
20650
|
+
media_vertical: verticalMediaSchema.optional(),
|
|
20641
20651
|
lineage: lineageSchema.default({ kind: "original", parents: [] }),
|
|
20642
20652
|
provenance: provenanceSchema.default({ subagent_models: [] }),
|
|
20643
20653
|
source: sourceSchema.optional()
|
|
@@ -20649,6 +20659,20 @@ var arcadeSchema = external_exports.object({
|
|
|
20649
20659
|
message: "A game can't be its own parent."
|
|
20650
20660
|
});
|
|
20651
20661
|
}
|
|
20662
|
+
if (a.orientation !== "landscape" && a.input.touch) {
|
|
20663
|
+
const v = a.media_vertical;
|
|
20664
|
+
const missing = [
|
|
20665
|
+
!v?.thumbnail && "a thumbnail",
|
|
20666
|
+
!v?.screenshots.length && "at least one screenshot",
|
|
20667
|
+
a.demo_video && !v?.demo_video && "a demo"
|
|
20668
|
+
].filter((x) => typeof x === "string");
|
|
20669
|
+
if (missing.length)
|
|
20670
|
+
ctx.addIssue({
|
|
20671
|
+
code: "custom",
|
|
20672
|
+
path: ["media_vertical"],
|
|
20673
|
+
message: `A ${a.orientation === "both" ? "game that also plays upright" : "portrait game"} needs vertical media from real play at 390x844: ${missing.join(", ")}.`
|
|
20674
|
+
});
|
|
20675
|
+
}
|
|
20652
20676
|
const ids = a.leaderboards.map((b) => b.id);
|
|
20653
20677
|
ids.forEach((id, i) => {
|
|
20654
20678
|
if (ids.indexOf(id) !== i)
|
|
@@ -20695,6 +20719,11 @@ var friendlyIssue = (iss) => {
|
|
|
20695
20719
|
if (limit) return `Use up to ${limit} screenshots.`;
|
|
20696
20720
|
if (missing) return "Add at least one screenshot.";
|
|
20697
20721
|
break;
|
|
20722
|
+
case "orientation":
|
|
20723
|
+
return 'Use "landscape", "portrait", or "both": the way the game plays on a phone.';
|
|
20724
|
+
case "media_vertical.screenshots":
|
|
20725
|
+
if (limit) return `Use up to ${limit} vertical screenshots.`;
|
|
20726
|
+
break;
|
|
20698
20727
|
case "lineage.kind":
|
|
20699
20728
|
return "lineage.kind was changed; re-run the arcade command that made this folder.";
|
|
20700
20729
|
case "lineage.parents":
|
|
@@ -21198,7 +21227,9 @@ function derived2(src, overrides) {
|
|
|
21198
21227
|
"screenshots",
|
|
21199
21228
|
"demo_video",
|
|
21200
21229
|
"preview_video",
|
|
21201
|
-
"preview_gif"
|
|
21230
|
+
"preview_gif",
|
|
21231
|
+
"orientation",
|
|
21232
|
+
"media_vertical"
|
|
21202
21233
|
];
|
|
21203
21234
|
const out2 = { schema: "arcade/v0" };
|
|
21204
21235
|
for (const k of keep) if (a[k] !== void 0) out2[k] = a[k];
|
|
@@ -21500,8 +21531,8 @@ function dev(dirArg, opts) {
|
|
|
21500
21531
|
|
|
21501
21532
|
// src/commands/publish.ts
|
|
21502
21533
|
import { execFileSync } from "child_process";
|
|
21503
|
-
import { existsSync as existsSync6, readFileSync as readFileSync8, statSync as statSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
21504
|
-
import { isAbsolute as isAbsolute2, join as join6, relative as relative2, resolve as resolve4, sep as sep4 } from "path";
|
|
21534
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync8, statSync as statSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
21535
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join6, relative as relative2, resolve as resolve4, sep as sep4 } from "path";
|
|
21505
21536
|
|
|
21506
21537
|
// src/files.ts
|
|
21507
21538
|
import { createHash as createHash2 } from "crypto";
|
|
@@ -21585,6 +21616,7 @@ function walkGame(root) {
|
|
|
21585
21616
|
|
|
21586
21617
|
// src/fixes.ts
|
|
21587
21618
|
var CAPTURE_GUIDE = 'arcade guide publishing, "Capturing media"';
|
|
21619
|
+
var VERTICAL = "media/vertical";
|
|
21588
21620
|
var thumbnailFix = (path) => `Run the game (arcade dev), capture a 1280x720 frame of real play, mid-action with no title screen or added text, and save it as ${path}.`;
|
|
21589
21621
|
var screenshotFix = (path) => `Capture a different moment of real play at 1280x720 and save it as ${path}. Aim for 2 to 4 screenshots in all.`;
|
|
21590
21622
|
var demoFix = (path) => `Record 15 to 30 s of real play at 1280x720 as H.264 and save it as ${path}, with the action starting in the first second (${CAPTURE_GUIDE}).`;
|
|
@@ -21614,6 +21646,10 @@ function fixFor(issue2, raw) {
|
|
|
21614
21646
|
return `${screenshotFix("media/shot-1.png")} List the paths in "screenshots".`;
|
|
21615
21647
|
if (path === "demo_video") return `${demoFix("media/demo.mp4")} Then set "demo_video".`;
|
|
21616
21648
|
if (path === "preview_video" || path === "preview_gif") return fromDemoFix;
|
|
21649
|
+
if (path === "orientation")
|
|
21650
|
+
return 'Play the game on a phone (390x844 with touch): "landscape" if it needs the phone sideways, "portrait" if it plays upright, "both" if it works either way.';
|
|
21651
|
+
if (path === "media_vertical" || path.startsWith("media_vertical."))
|
|
21652
|
+
return `Run the game at 390x844 with touch emulation and capture upright play into ${VERTICAL}/ (thumbnail.png, shot-1.png, and demo.mp4 if the game has a landscape demo), list them under "media_vertical", then run \`arcade media vertical\` for the preview and GIF (${CAPTURE_GUIDE}). If the game really only plays sideways, set "orientation": "landscape" instead.`;
|
|
21617
21653
|
if (path === "title") return `Set "title" to the game's name as players should see it.`;
|
|
21618
21654
|
if (path === "description")
|
|
21619
21655
|
return 'Set "description" to one or two sentences: the fantasy and the goal.';
|
|
@@ -21629,6 +21665,10 @@ function missingMediaLine(path, raw) {
|
|
|
21629
21665
|
if (raw.demo_video === path) return `${path} (demo): ${demoFix(path)}`;
|
|
21630
21666
|
if (raw.preview_video === path) return `${path} (hover preview): ${fromDemoFix}`;
|
|
21631
21667
|
if (raw.preview_gif === path) return `${path} (GIF): ${fromDemoFix}`;
|
|
21668
|
+
const v = raw.media_vertical ?? {};
|
|
21669
|
+
const vertical = [v.thumbnail, v.demo_video, v.preview_video, v.preview_gif].includes(path) || Array.isArray(v.screenshots) && v.screenshots.includes(path);
|
|
21670
|
+
if (vertical)
|
|
21671
|
+
return `${path} (vertical): ${raw.orientation === "portrait" || raw.orientation === "both" ? `capture it from real play at 390x844 with touch; previews and GIFs come from \`arcade media vertical\`.` : "run `arcade media vertical` to make the vertical set from the landscape media."}`;
|
|
21632
21672
|
return path;
|
|
21633
21673
|
}
|
|
21634
21674
|
|
|
@@ -22020,8 +22060,9 @@ function runFfmpeg(args, what) {
|
|
|
22020
22060
|
throw new ExitError(`ffmpeg couldn't make the ${what}${last ? `: ${last}` : "."}`);
|
|
22021
22061
|
}
|
|
22022
22062
|
}
|
|
22023
|
-
function makePreview(dir, demo, target = "media/preview.mp4") {
|
|
22063
|
+
function makePreview(dir, demo, target = "media/preview.mp4", scale = "scale=854:-2") {
|
|
22024
22064
|
if (!hasFfmpeg()) return null;
|
|
22065
|
+
mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
|
|
22025
22066
|
runFfmpeg(
|
|
22026
22067
|
[
|
|
22027
22068
|
"-i",
|
|
@@ -22030,7 +22071,7 @@ function makePreview(dir, demo, target = "media/preview.mp4") {
|
|
|
22030
22071
|
"12",
|
|
22031
22072
|
"-an",
|
|
22032
22073
|
"-vf",
|
|
22033
|
-
|
|
22074
|
+
`${scale},fps=30`,
|
|
22034
22075
|
"-c:v",
|
|
22035
22076
|
"libx264",
|
|
22036
22077
|
"-preset",
|
|
@@ -22048,9 +22089,13 @@ function makePreview(dir, demo, target = "media/preview.mp4") {
|
|
|
22048
22089
|
return target;
|
|
22049
22090
|
}
|
|
22050
22091
|
var MAX_GIF_BYTES = 5 * 1024 * 1024;
|
|
22051
|
-
function makeGif(dir, demo, target = "media/preview.gif") {
|
|
22092
|
+
function makeGif(dir, demo, target = "media/preview.gif", vertical = false) {
|
|
22052
22093
|
if (!hasFfmpeg()) return null;
|
|
22053
|
-
|
|
22094
|
+
mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
|
|
22095
|
+
const tries = vertical ? [
|
|
22096
|
+
{ width: 360, fps: 12, seconds: 6 },
|
|
22097
|
+
{ width: 270, fps: 10, seconds: 5 }
|
|
22098
|
+
] : [
|
|
22054
22099
|
{ width: 480, fps: 12, seconds: 6 },
|
|
22055
22100
|
{ width: 360, fps: 10, seconds: 5 }
|
|
22056
22101
|
];
|
|
@@ -22076,6 +22121,155 @@ function makeGif(dir, demo, target = "media/preview.gif") {
|
|
|
22076
22121
|
`The GIF came out over ${MAX_GIF_BYTES / 1024 / 1024} MB, even small. Re-cut the demo so its first 5 seconds are less busy (a later -ss), then run \`arcade media preview\` again.`
|
|
22077
22122
|
);
|
|
22078
22123
|
}
|
|
22124
|
+
var VERTICAL_W = 1080;
|
|
22125
|
+
var VERTICAL_H = 1920;
|
|
22126
|
+
var BLURRED_FILL = [
|
|
22127
|
+
"split=2[bg][fg]",
|
|
22128
|
+
`[bg]scale=${VERTICAL_W}:${VERTICAL_H}:force_original_aspect_ratio=increase,crop=${VERTICAL_W}:${VERTICAL_H},scale=270:480,gblur=sigma=10,scale=${VERTICAL_W}:${VERTICAL_H},eq=brightness=-0.22:saturation=1.1[b]`,
|
|
22129
|
+
`[fg]scale=${VERTICAL_W}:-2[f]`,
|
|
22130
|
+
"[b][f]overlay=(W-w)/2:(H-h)/2"
|
|
22131
|
+
].join(";");
|
|
22132
|
+
var MAX_VIDEO_BYTES = 25 * 1024 * 1024;
|
|
22133
|
+
var VERTICAL_DIR = "media/vertical";
|
|
22134
|
+
function fillImage(dir, src, target) {
|
|
22135
|
+
if (!hasFfmpeg()) return null;
|
|
22136
|
+
mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
|
|
22137
|
+
runFfmpeg(
|
|
22138
|
+
[
|
|
22139
|
+
"-i",
|
|
22140
|
+
join6(dir, src),
|
|
22141
|
+
"-filter_complex",
|
|
22142
|
+
BLURRED_FILL,
|
|
22143
|
+
"-frames:v",
|
|
22144
|
+
"1",
|
|
22145
|
+
"-q:v",
|
|
22146
|
+
"3",
|
|
22147
|
+
join6(dir, target)
|
|
22148
|
+
],
|
|
22149
|
+
`vertical version of ${src}`
|
|
22150
|
+
);
|
|
22151
|
+
return target;
|
|
22152
|
+
}
|
|
22153
|
+
function fillDemo(dir, demo, target = `${VERTICAL_DIR}/demo.mp4`) {
|
|
22154
|
+
if (!hasFfmpeg()) return null;
|
|
22155
|
+
mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
|
|
22156
|
+
for (const crf of ["28", "34"]) {
|
|
22157
|
+
runFfmpeg(
|
|
22158
|
+
[
|
|
22159
|
+
"-i",
|
|
22160
|
+
join6(dir, demo),
|
|
22161
|
+
"-t",
|
|
22162
|
+
"30",
|
|
22163
|
+
"-an",
|
|
22164
|
+
"-filter_complex",
|
|
22165
|
+
`${BLURRED_FILL},fps=30,format=yuv420p`,
|
|
22166
|
+
"-c:v",
|
|
22167
|
+
"libx264",
|
|
22168
|
+
"-preset",
|
|
22169
|
+
"veryfast",
|
|
22170
|
+
"-crf",
|
|
22171
|
+
crf,
|
|
22172
|
+
"-movflags",
|
|
22173
|
+
"+faststart",
|
|
22174
|
+
join6(dir, target)
|
|
22175
|
+
],
|
|
22176
|
+
"vertical demo"
|
|
22177
|
+
);
|
|
22178
|
+
if (statSync5(join6(dir, target)).size <= MAX_VIDEO_BYTES) return target;
|
|
22179
|
+
}
|
|
22180
|
+
throw new ExitError(
|
|
22181
|
+
`The vertical demo came out over ${MAX_VIDEO_BYTES / 1024 / 1024} MB. Record a shorter demo (15 to 20 s), then run \`arcade media vertical\` again.`
|
|
22182
|
+
);
|
|
22183
|
+
}
|
|
22184
|
+
var orientationOf = (raw) => raw.orientation === "portrait" || raw.orientation === "both" ? raw.orientation : "landscape";
|
|
22185
|
+
var verticalOf = (raw) => raw.media_vertical && typeof raw.media_vertical === "object" ? { ...raw.media_vertical } : {};
|
|
22186
|
+
var exists = (dir, p) => typeof p === "string" && existsSync6(join6(dir, p));
|
|
22187
|
+
function fillVerticalSet(dir, raw) {
|
|
22188
|
+
const v = verticalOf(raw);
|
|
22189
|
+
const made = [];
|
|
22190
|
+
const skipped = [];
|
|
22191
|
+
const image = (src, target) => {
|
|
22192
|
+
try {
|
|
22193
|
+
made.push(fillImage(dir, src, target));
|
|
22194
|
+
return target;
|
|
22195
|
+
} catch (e) {
|
|
22196
|
+
if (!(e instanceof ExitError)) throw e;
|
|
22197
|
+
skipped.push(e.message);
|
|
22198
|
+
return null;
|
|
22199
|
+
}
|
|
22200
|
+
};
|
|
22201
|
+
if (exists(dir, raw.thumbnail)) {
|
|
22202
|
+
const t = image(raw.thumbnail, `${VERTICAL_DIR}/thumbnail.jpg`);
|
|
22203
|
+
if (t) v.thumbnail = t;
|
|
22204
|
+
}
|
|
22205
|
+
if (Array.isArray(raw.screenshots)) {
|
|
22206
|
+
v.screenshots = raw.screenshots.filter((p) => exists(dir, p)).map((p, i) => image(p, `${VERTICAL_DIR}/shot-${i + 1}.jpg`)).filter((p) => p !== null);
|
|
22207
|
+
}
|
|
22208
|
+
if (exists(dir, raw.demo_video)) {
|
|
22209
|
+
v.demo_video = fillDemo(dir, raw.demo_video);
|
|
22210
|
+
v.preview_video = makePreview(
|
|
22211
|
+
dir,
|
|
22212
|
+
v.demo_video,
|
|
22213
|
+
`${VERTICAL_DIR}/preview.mp4`,
|
|
22214
|
+
"scale=-2:854"
|
|
22215
|
+
);
|
|
22216
|
+
v.preview_gif = makeGif(dir, v.demo_video, `${VERTICAL_DIR}/preview.gif`, true);
|
|
22217
|
+
made.push(v.demo_video, v.preview_video, v.preview_gif);
|
|
22218
|
+
}
|
|
22219
|
+
if (made.length) raw.media_vertical = v;
|
|
22220
|
+
return { made, skipped };
|
|
22221
|
+
}
|
|
22222
|
+
function ensureVertical(dir, raw) {
|
|
22223
|
+
const v = verticalOf(raw);
|
|
22224
|
+
const save = () => writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
|
|
22225
|
+
`);
|
|
22226
|
+
if (orientationOf(raw) === "landscape") {
|
|
22227
|
+
const needDemo = exists(dir, raw.demo_video) && !v.demo_video;
|
|
22228
|
+
const needThumb = exists(dir, raw.thumbnail) && !v.thumbnail;
|
|
22229
|
+
if (!needDemo && !needThumb) return;
|
|
22230
|
+
if (!hasFfmpeg()) {
|
|
22231
|
+
if (needDemo)
|
|
22232
|
+
throw new ExitError(
|
|
22233
|
+
"This game has a demo, so phones need a vertical cut of it (over a blurred fill), and that takes ffmpeg.",
|
|
22234
|
+
2,
|
|
22235
|
+
[NO_FFMPEG],
|
|
22236
|
+
"Then run `arcade media vertical` (or publish again, which makes it for you)."
|
|
22237
|
+
);
|
|
22238
|
+
out(
|
|
22239
|
+
dim("No vertical thumbnail: ffmpeg isn't installed. Phones will show the landscape one.")
|
|
22240
|
+
);
|
|
22241
|
+
return;
|
|
22242
|
+
}
|
|
22243
|
+
const { made, skipped } = fillVerticalSet(dir, raw);
|
|
22244
|
+
if (made.length) save();
|
|
22245
|
+
if (made.length)
|
|
22246
|
+
out(
|
|
22247
|
+
dim(
|
|
22248
|
+
`Made the vertical set for phones, over a blurred fill (${plural(made.length, "file")} in ${VERTICAL_DIR}/).`
|
|
22249
|
+
)
|
|
22250
|
+
);
|
|
22251
|
+
for (const m of skipped)
|
|
22252
|
+
out(yellow(`No vertical copy: ${m} Phones will show the landscape one.`));
|
|
22253
|
+
return;
|
|
22254
|
+
}
|
|
22255
|
+
if (exists(dir, v.demo_video) && !v.preview_gif) {
|
|
22256
|
+
if (!hasFfmpeg())
|
|
22257
|
+
throw new ExitError("The vertical demo needs a GIF made from it, and that takes ffmpeg.", 2, [
|
|
22258
|
+
NO_FFMPEG
|
|
22259
|
+
]);
|
|
22260
|
+
const made = cutVerticalPreviews(dir, raw);
|
|
22261
|
+
save();
|
|
22262
|
+
out(dim(`Made a vertical preview and GIF from the vertical demo (${made.join(", ")}).`));
|
|
22263
|
+
}
|
|
22264
|
+
}
|
|
22265
|
+
function cutVerticalPreviews(dir, raw) {
|
|
22266
|
+
const v = verticalOf(raw);
|
|
22267
|
+
if (!exists(dir, v.demo_video)) return [];
|
|
22268
|
+
v.preview_video = makePreview(dir, v.demo_video, `${VERTICAL_DIR}/preview.mp4`, "scale=-2:854");
|
|
22269
|
+
v.preview_gif = makeGif(dir, v.demo_video, `${VERTICAL_DIR}/preview.gif`, true);
|
|
22270
|
+
raw.media_vertical = v;
|
|
22271
|
+
return [v.preview_video, v.preview_gif];
|
|
22272
|
+
}
|
|
22079
22273
|
var NO_FFMPEG = "ffmpeg isn't installed. Install it (macOS: `brew install ffmpeg`, Ubuntu: `sudo apt install ffmpeg`, Windows: `winget install ffmpeg`) and try again.";
|
|
22080
22274
|
function demoFor(dir, raw, what) {
|
|
22081
22275
|
if (typeof raw.demo_video !== "string")
|
|
@@ -22116,6 +22310,36 @@ async function mediaGif(dirArg) {
|
|
|
22116
22310
|
`);
|
|
22117
22311
|
out(`Made ${gif} (${mb(statSync5(join6(dir, gif)).size)}) and added it to arcade.json.`);
|
|
22118
22312
|
}
|
|
22313
|
+
async function mediaVertical(dirArg) {
|
|
22314
|
+
const dir = resolve4(dirArg ?? ".");
|
|
22315
|
+
const raw = readArcadeRaw(dir);
|
|
22316
|
+
if (!hasFfmpeg()) throw new ExitError(NO_FFMPEG);
|
|
22317
|
+
const orientation = orientationOf(raw);
|
|
22318
|
+
let made;
|
|
22319
|
+
if (orientation === "landscape") {
|
|
22320
|
+
if (!exists(dir, raw.thumbnail))
|
|
22321
|
+
throw new ExitError(
|
|
22322
|
+
`The vertical set is made from the landscape media, and ${typeof raw.thumbnail === "string" ? `${raw.thumbnail} isn't in the folder yet` : "arcade.json has no thumbnail"}. Capture the landscape media first (${CAPTURE_GUIDE}).`,
|
|
22323
|
+
2
|
|
22324
|
+
);
|
|
22325
|
+
const r = fillVerticalSet(dir, raw);
|
|
22326
|
+
made = r.made;
|
|
22327
|
+
for (const m of r.skipped) out(yellow(`Skipped: ${m}`));
|
|
22328
|
+
} else {
|
|
22329
|
+
made = cutVerticalPreviews(dir, raw);
|
|
22330
|
+
if (!made.length)
|
|
22331
|
+
throw new ExitError(
|
|
22332
|
+
`This game plays ${orientation === "portrait" ? "upright" : "both ways"}, so its vertical media comes from real upright play, not a blurred fill. Record 15 to 30 s at 390x844 with touch into ${VERTICAL_DIR}/demo.mp4, set "media_vertical.demo_video", then run this again (${CAPTURE_GUIDE}).`,
|
|
22333
|
+
2
|
|
22334
|
+
);
|
|
22335
|
+
}
|
|
22336
|
+
writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
|
|
22337
|
+
`);
|
|
22338
|
+
out(
|
|
22339
|
+
`Made ${plural(made.length, "vertical file")} (${orientation === "landscape" ? "landscape media over a blurred fill, 1080x1920" : "cut from your vertical demo"}) and set media_vertical:`
|
|
22340
|
+
);
|
|
22341
|
+
for (const m of made) out(` ${m} ${dim(mb(statSync5(join6(dir, m)).size))}`);
|
|
22342
|
+
}
|
|
22119
22343
|
function describeAction(p, parentNames) {
|
|
22120
22344
|
switch (p.action) {
|
|
22121
22345
|
case "original":
|
|
@@ -22175,6 +22399,19 @@ function listWords(words) {
|
|
|
22175
22399
|
if (words.length <= 1) return words.join("");
|
|
22176
22400
|
return `${words.slice(0, -1).join(", ")}${words.length > 2 ? "," : ""} and ${words.at(-1)}`;
|
|
22177
22401
|
}
|
|
22402
|
+
var FULLSCREEN_CALL = /\b(?:request|webkitRequest|mozRequest|msRequest)Full[sS]creen\b/;
|
|
22403
|
+
var CODE_FILE = /\.(?:html?|m?js|jsx|tsx?)$/i;
|
|
22404
|
+
function fullscreenCalls(files) {
|
|
22405
|
+
const hits = [];
|
|
22406
|
+
for (const f of files) {
|
|
22407
|
+
if (!CODE_FILE.test(f.path) || f.path.startsWith("parents/") || f.size > 2 * 1024 * 1024)
|
|
22408
|
+
continue;
|
|
22409
|
+
const lines = readFileSync8(f.abs, "utf8").split("\n");
|
|
22410
|
+
const i = lines.findIndex((l) => FULLSCREEN_CALL.test(l));
|
|
22411
|
+
if (i >= 0) hits.push(`${f.path}:${i + 1}`);
|
|
22412
|
+
}
|
|
22413
|
+
return hits;
|
|
22414
|
+
}
|
|
22178
22415
|
var HOME_PATH = /(?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\)[^\s/\\"'`]+/;
|
|
22179
22416
|
function headsUp(arcade, files, alreadyOnArcade, secretWarnings, dir) {
|
|
22180
22417
|
const notes = [];
|
|
@@ -22261,6 +22498,26 @@ function headsUp(arcade, files, alreadyOnArcade, secretWarnings, dir) {
|
|
|
22261
22498
|
notes.push(
|
|
22262
22499
|
`The ${stale.map(([w]) => w).join(" and ")} ${stale.length > 1 ? "are" : "is"} older than the demo. Run \`arcade media preview\` to remake ${stale.length > 1 ? "them" : "it"}.`
|
|
22263
22500
|
);
|
|
22501
|
+
const v = arcade.media_vertical;
|
|
22502
|
+
if (arcade.orientation === "landscape") {
|
|
22503
|
+
if (!v?.thumbnail)
|
|
22504
|
+
notes.push(
|
|
22505
|
+
"There's no vertical thumbnail, so phones show the landscape one. Run `arcade media vertical` (it needs ffmpeg)."
|
|
22506
|
+
);
|
|
22507
|
+
const vStale = [
|
|
22508
|
+
["vertical thumbnail", age(v?.thumbnail), age(arcade.thumbnail)],
|
|
22509
|
+
["vertical demo", age(v?.demo_video), demoAt]
|
|
22510
|
+
].filter(([, t, src]) => t !== null && src !== null && t < src);
|
|
22511
|
+
if (vStale.length)
|
|
22512
|
+
notes.push(
|
|
22513
|
+
`The ${vStale.map(([w]) => w).join(" and ")} ${vStale.length > 1 ? "are" : "is"} older than the landscape media. Run \`arcade media vertical\` to remake the vertical set.`
|
|
22514
|
+
);
|
|
22515
|
+
}
|
|
22516
|
+
const fullscreen = fullscreenCalls(files);
|
|
22517
|
+
if (fullscreen.length)
|
|
22518
|
+
notes.push(
|
|
22519
|
+
`${listWords(fullscreen.slice(0, 3))}${fullscreen.length > 3 ? ` and ${fullscreen.length - 3} more` : ""} ${fullscreen.length > 1 ? "call" : "calls"} requestFullscreen: the arcade's player owns fullscreen; remove your fullscreen button.`
|
|
22520
|
+
);
|
|
22264
22521
|
const thumb = files.find((f) => f.path === arcade.thumbnail);
|
|
22265
22522
|
if (thumb && thumb.size > 500 * 1024) {
|
|
22266
22523
|
const webp = arcade.thumbnail.replace(/\.[^.]+$/, ".webp");
|
|
@@ -22344,6 +22601,7 @@ async function publish(dirArg, opts) {
|
|
|
22344
22601
|
`);
|
|
22345
22602
|
out(dim(`Made a GIF from the demo (${made}, ${mb(statSync5(join6(dir, made)).size)}).`));
|
|
22346
22603
|
}
|
|
22604
|
+
ensureVertical(dir, raw);
|
|
22347
22605
|
const prov = raw.provenance ?? {};
|
|
22348
22606
|
const orch = prov.orchestrator ?? null;
|
|
22349
22607
|
const named = opts.session ?? [];
|
|
@@ -22396,6 +22654,14 @@ async function publish(dirArg, opts) {
|
|
|
22396
22654
|
throw new ExitError("A game with a demo needs a GIF made from it (preview_gif).", 2, [
|
|
22397
22655
|
`${arcade.demo_video} (demo): record it first, then run \`arcade media preview\`.`
|
|
22398
22656
|
]);
|
|
22657
|
+
if (arcade.media_vertical?.demo_video && !arcade.media_vertical.preview_gif)
|
|
22658
|
+
throw new ExitError(
|
|
22659
|
+
"The vertical demo needs a GIF made from it (media_vertical.preview_gif).",
|
|
22660
|
+
2,
|
|
22661
|
+
[
|
|
22662
|
+
`${arcade.media_vertical.demo_video} (vertical demo): record it first, then run \`arcade media vertical\`.`
|
|
22663
|
+
]
|
|
22664
|
+
);
|
|
22399
22665
|
await fillLicenseHolder(dir);
|
|
22400
22666
|
const { files, skipped, skippedLinks } = walkGame(dir);
|
|
22401
22667
|
const bad = files.filter((f) => !f.type);
|
|
@@ -22413,7 +22679,12 @@ async function publish(dirArg, opts) {
|
|
|
22413
22679
|
...arcade.screenshots,
|
|
22414
22680
|
arcade.demo_video,
|
|
22415
22681
|
arcade.preview_video,
|
|
22416
|
-
arcade.preview_gif
|
|
22682
|
+
arcade.preview_gif,
|
|
22683
|
+
arcade.media_vertical?.thumbnail,
|
|
22684
|
+
...arcade.media_vertical?.screenshots ?? [],
|
|
22685
|
+
arcade.media_vertical?.demo_video,
|
|
22686
|
+
arcade.media_vertical?.preview_video,
|
|
22687
|
+
arcade.media_vertical?.preview_gif
|
|
22417
22688
|
];
|
|
22418
22689
|
const missingMedia = media.filter((m) => !!m && !files.some((f) => f.path === m));
|
|
22419
22690
|
if (missingMedia.length) {
|
|
@@ -22705,12 +22976,12 @@ async function setPublished(slug, published) {
|
|
|
22705
22976
|
}
|
|
22706
22977
|
|
|
22707
22978
|
// src/commands/skills.ts
|
|
22708
|
-
import { cpSync, existsSync as existsSync7, mkdirSync as
|
|
22979
|
+
import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync9 } from "fs";
|
|
22709
22980
|
import { homedir as homedir4 } from "os";
|
|
22710
|
-
import { dirname as
|
|
22981
|
+
import { dirname as dirname4, join as join7, resolve as resolve5 } from "path";
|
|
22711
22982
|
import { fileURLToPath } from "url";
|
|
22712
22983
|
function skillsDir() {
|
|
22713
|
-
const here =
|
|
22984
|
+
const here = dirname4(fileURLToPath(import.meta.url));
|
|
22714
22985
|
for (const candidate2 of [join7(here, "..", "skills"), join7(here, "..", "..", "skills")]) {
|
|
22715
22986
|
if (existsSync7(join7(candidate2, "arcade-getting-started", "SKILL.md"))) return candidate2;
|
|
22716
22987
|
}
|
|
@@ -22763,7 +23034,7 @@ function installSkills(opts) {
|
|
|
22763
23034
|
return f();
|
|
22764
23035
|
});
|
|
22765
23036
|
for (const target of targets) {
|
|
22766
|
-
|
|
23037
|
+
mkdirSync4(target, { recursive: true });
|
|
22767
23038
|
for (const name of names)
|
|
22768
23039
|
cpSync(join7(src, name), join7(target, name), { recursive: true, force: true });
|
|
22769
23040
|
out(`Installed ${names.length} skills into ${target}`);
|
|
@@ -22868,6 +23139,9 @@ function buildProgram() {
|
|
|
22868
23139
|
"remake media/preview.mp4 (12 s) and media/preview.gif (6 s) from your demo, after every new demo"
|
|
22869
23140
|
).action(mediaPreview);
|
|
22870
23141
|
media.command("gif [dir]").description("remake only media/preview.gif from your demo").action(mediaGif);
|
|
23142
|
+
media.command("vertical [dir]").description(
|
|
23143
|
+
"make the phone (9:16) set in media/vertical/: landscape games get their media over a blurred fill at 1080x1920; portrait and both games get a preview and GIF from their vertical demo"
|
|
23144
|
+
).action(mediaVertical);
|
|
22871
23145
|
program.command("main <slug> <generation>").description("(owner) pick which generation is main for its version").action(setMain);
|
|
22872
23146
|
program.command("unpublish <slug>").description("(owner) take your game down").action((slug) => setPublished(slug, false));
|
|
22873
23147
|
program.command("republish <slug>").description("(owner) bring your game back").action((slug) => setPublished(slug, true));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evolutionary-arcade",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "The arcade CLI for Evolutionary Arcade. Publish, update, regen, fork, and blend AI-made browser games with your own coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -59,7 +59,7 @@ someone else's game, see `arcade-remix-and-blend`.
|
|
|
59
59
|
files by relative URL is fine. `arcade dev` lets `ws:` through but the arcade doesn't, so don't
|
|
60
60
|
trust dev for sockets. Don't add tracking code; the arcade measures play from outside the frame.
|
|
61
61
|
4. **Start on a click or keypress.** Show a title screen with "Click to play". Create or resume the
|
|
62
|
-
`AudioContext` inside that handler. Request pointer lock
|
|
62
|
+
`AudioContext` inside that handler. Request pointer lock only from a gesture. Esc
|
|
63
63
|
and gamepad buttons don't count as gestures, so a controller-only player can't turn on sound.
|
|
64
64
|
Show "click or press any key once."
|
|
65
65
|
5. **Pointer lock comes from the sandbox token.** The arcade grants `allow-pointer-lock`, and Chrome
|
|
@@ -97,7 +97,15 @@ someone else's game, see `arcade-remix-and-blend`.
|
|
|
97
97
|
like every game here. Anything you do bundle from others, like a vendored Three.js, keeps its
|
|
98
98
|
own license: ship its LICENSE beside it (`vendor/three/LICENSE`), and only bundle what's yours
|
|
99
99
|
to share.
|
|
100
|
-
10. **
|
|
100
|
+
10. **Don't build fullscreen. The platform owns it.** The arcade's player has the fullscreen
|
|
101
|
+
button, and on phones the game page's Play button opens the game straight into it. It uses
|
|
102
|
+
the Fullscreen API where there is one, covers the whole page on iPhone (which has none for
|
|
103
|
+
games), and locks the orientation on Android. So no
|
|
104
|
+
fullscreen button, no `requestFullscreen()` or `webkitRequestFullscreen()`, and no
|
|
105
|
+
`screen.orientation.lock()` of your own. Size the game to its frame (`resize` events) and it
|
|
106
|
+
works full-size. `arcade publish` warns when your code calls requestFullscreen: "the arcade's
|
|
107
|
+
player owns fullscreen; remove your fullscreen button".
|
|
108
|
+
11. **Storage is optional, and shared.** `localStorage` works (a saved high score is nice), but wrap
|
|
101
109
|
every access in try/catch. Every version and generation of a game shares one origin, including
|
|
102
110
|
other people's regens. Prefix keys with your slug, version your save format, and handle old or
|
|
103
111
|
malformed data. To keep progress across devices, use profile saves (below).
|
|
@@ -259,7 +267,7 @@ a game's history is a record of what each model could build.
|
|
|
259
267
|
- **Touch:** without `input.touch: true`, phones get your demo video instead of the game. Set it
|
|
260
268
|
only if the game has real touch controls, like an on-screen stick and buttons with large targets
|
|
261
269
|
(48 px or more). Use pointer events, put `touch-action: none` on the canvas, and don't rely on
|
|
262
|
-
hover. Test at phone width
|
|
270
|
+
hover. Test at phone width (390x844 with touch), where the player opens fullscreen. To
|
|
263
271
|
try it on a real phone, run `arcade dev --host` and open the network address it prints on a
|
|
264
272
|
phone on the same Wi-Fi.
|
|
265
273
|
- **Tilt:** see Phone tilt, next.
|
|
@@ -282,21 +290,18 @@ that have touch on, and every tilt game needs touch controls anyway.
|
|
|
282
290
|
little. Put "Recenter" in the pause menu.
|
|
283
291
|
- **Turn the axes with the screen.** `gamma` is left-right only in portrait. In landscape it's
|
|
284
292
|
`beta`, and the sign flips with the side the phone is turned to (`screen.orientation.angle`).
|
|
285
|
-
- **
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
293
|
+
- **Orientation is the platform's job.** Set `orientation` in arcade.json (`"landscape"`,
|
|
294
|
+
`"portrait"`, or `"both"`) and don't lock it yourself; the arcade's fullscreen player locks
|
|
295
|
+
the screen on Android. iPhones never lock, so a landscape game shows "Turn your phone sideways"
|
|
296
|
+
while it's held upright.
|
|
289
297
|
|
|
290
298
|
```js
|
|
291
|
-
const LANDSCAPE = true; // a landscape-first game
|
|
292
299
|
let tiltOk = false, zero = null, tilt = 0; // tilt: -1..1; steer by touch until tiltOk
|
|
293
300
|
async function onStartTap() { // the "Tap to play" handler
|
|
294
301
|
const DOE = globalThis.DeviceOrientationEvent;
|
|
295
302
|
const asking = typeof DOE?.requestPermission === "function"
|
|
296
303
|
? DOE.requestPermission().catch(() => "denied") // iPhone: asks, so call it before any await
|
|
297
304
|
: Promise.resolve(DOE ? "granted" : "denied");
|
|
298
|
-
if (LANDSCAPE) document.documentElement.requestFullscreen?.()
|
|
299
|
-
.then(() => screen.orientation.lock("landscape")).catch(() => {}); // Android only
|
|
300
305
|
tiltOk = (await asking) === "granted";
|
|
301
306
|
zero = null; // "Recenter" does this too
|
|
302
307
|
startOrResume();
|
|
@@ -55,7 +55,7 @@ Play-test the idea before you touch code. Ask these together, in one message, an
|
|
|
55
55
|
> 2. What should stay? The thing you liked most about the original. (default: its core feel)
|
|
56
56
|
> 3. A name for your game? It becomes its address, evolutionaryarcade.com/g/<name>, and can't change later. (default: I'll suggest one from your idea)
|
|
57
57
|
> 4. Phones: want touch controls so people watching on their phones can play it? (default: yes, if the original doesn't have them)
|
|
58
|
-
> 5. Want me to record a short demo so your game page has a video and a GIF to share? (default: yes)"
|
|
58
|
+
> 5. Want me to record a short demo so your game page has a video and a GIF to share? It comes in two shapes, wide for computers and tall for phones. (default: yes)"
|
|
59
59
|
|
|
60
60
|
**For a blend**, replace question 1 and 2 with: "Which game's loop is the spine? I'll assume <spine>, with <donor> giving one thing: its movement, its look, an enemy, a mechanic. Which thing do you want from <donor>?" and say in one sentence what the blend would be ("Fly a starfighter to defend a deep-space relay from drone waves"). If they can't say it without "and also", it's two games; help them pick one.
|
|
61
61
|
|
|
@@ -105,9 +105,11 @@ Play it after every meaningful change, with real inputs through Playwright again
|
|
|
105
105
|
|
|
106
106
|
## Step 6. Media, demo, and GIF
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
Estimate (say it's an estimate): about 5 minutes for a landscape game's media and demo, the vertical set included, since `arcade media vertical` makes it; about 10 for a portrait or both game, which records upright play too.
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
Capture new media from **their** build: a fork arrives with the parent's files, and a card showing the parent's footage misrepresents the game. A 1280x720 thumbnail mid-action and 2-4 screenshots from real play, plus the vertical (phone) set in the same pass: check `orientation` still fits their version (play it at 390x844 with touch), then capture upright play into `media/vertical/` for a portrait or both game, or run `arcade media vertical` for a landscape one, which puts the landscape media over a blurred fill (`arcade guide publishing`, "Capturing media" and "Orientation and the vertical set"). The parent's vertical files are the parent's footage too, so remake them. If the game has its own fullscreen button, remove it: the arcade's player owns fullscreen.
|
|
111
|
+
|
|
112
|
+
If they said yes to a demo (the default): record 15-30 s of real play at 1280x720 with the action in the first second, save it as `media/demo.mp4`, set `"demo_video"`, and run `arcade media preview`. That makes the hover preview and `media/preview.gif`. For a portrait or both game, also record upright play at 390x844 into `media/vertical/demo.mp4`; then run `arcade media vertical` either way. Look at both GIFs before moving on. Keep raw recordings outside the game folder; everything in it is uploaded.
|
|
111
113
|
|
|
112
114
|
## Step 7. One dry run, one go, publish
|
|
113
115
|
|
|
@@ -97,7 +97,7 @@ For each candidate, decide from facts, then build one numbered table. It goes in
|
|
|
97
97
|
|
|
98
98
|
Verdicts: **ready** (publishes as-is once it has metadata and media), **small fixes** (absolute paths, a CDN import to vendor, a build step, a stray big file), **not a fit** (needs a server, no web build, mostly someone else's work). Give the reason in a few words.
|
|
99
99
|
|
|
100
|
-
**Time estimates are yours.** Look at the code before you estimate, and say they're estimates. As a starting point: as-is with metadata and media takes you a few minutes per game; touch controls 10-20 min; gamepad 5-10; saves or a leaderboard 5-10 each; a demo 3-5. Adjust for what you see.
|
|
100
|
+
**Time estimates are yours.** Look at the code before you estimate, and say they're estimates. As a starting point: as-is with metadata and media takes you a few minutes per game, including the vertical (phone) set: about 1 more minute for a landscape game (`arcade media vertical` makes it), 3-5 for a portrait or both game (an upright capture at 390x844); touch controls 10-20 min; gamepad 5-10; saves or a leaderboard 5-10 each; a demo 3-5 (twice that for a portrait or both game, which records a vertical demo too); removing a game's own fullscreen button 2-5. Adjust for what you see.
|
|
101
101
|
|
|
102
102
|
```
|
|
103
103
|
# Game Where Fit What it needs As-is / optimized
|
|
@@ -117,7 +117,9 @@ Follow their step-1 answer for the whole batch.
|
|
|
117
117
|
- write `arcade.json` (`arcade guide publishing` has a full example): a slug you pick with them (permanent, it's the game's address), the title, a one-or-two-sentence description, exact `controls`, and honest `input` flags;
|
|
118
118
|
- write `LICENSE` (MIT, "Copyright (c) <year> @<handle>") unless they already have an MIT one;
|
|
119
119
|
- run it with `arcade dev`, fix anything that breaks under the arcade's CSP (outside requests, absolute paths);
|
|
120
|
-
-
|
|
120
|
+
- decide its `orientation` by playing it at 390x844 with touch: `"landscape"` if it needs the phone sideways (fine), `"portrait"` if it plays upright, `"both"` if either works;
|
|
121
|
+
- capture a 1280x720 thumbnail mid-action and 2-4 screenshots from real play, and in the same pass the vertical set: for portrait or both, an upright thumbnail and 2-4 screenshots at 390x844 with touch emulation into `media/vertical/`; for landscape, run `arcade media vertical`, which puts the landscape media over a blurred fill (`arcade guide publishing`, "Capturing media" and "Orientation and the vertical set");
|
|
122
|
+
- remove any fullscreen button of the game's own and its `requestFullscreen` calls: the arcade's player owns fullscreen (the dry run warns about them);
|
|
121
123
|
- fill the Model card with what they tell you (the model and harness they used); leave out anything unknown. Never invent numbers.
|
|
122
124
|
|
|
123
125
|
**Optimize** adds, per game, only what makes sense for it, using the arcade's format (`arcade guide building-games` has each one):
|
|
@@ -131,7 +133,7 @@ Play every game after you change it. Set an input flag only when that path works
|
|
|
131
133
|
|
|
132
134
|
## Step 6. Demos
|
|
133
135
|
|
|
134
|
-
If they said yes in step 1 (the default), then for each game: play it with Playwright at 1280x720 against `arcade dev` (drive real inputs, or the game's autopilot if it has one), cut 15-30 s with the action in the first second, save it as `media/demo.mp4`, set `"demo_video"`, and run `arcade media preview`. That makes `media/preview.mp4` (the hover preview) and `media/preview.gif`, and sets both fields. **A game with a demo must have its GIF**; `arcade publish` makes it when it's missing and refuses without ffmpeg. Watch the clips and look at the GIF before moving on. Bot-driven footage is fine; say so in `provenance.notes`. Keep recordings outside the game folder, since everything in it is uploaded. Recording needs Playwright and ffmpeg; if they're missing, ask before installing.
|
|
136
|
+
If they said yes in step 1 (the default), then for each game: play it with Playwright at 1280x720 against `arcade dev` (drive real inputs, or the game's autopilot if it has one), cut 15-30 s with the action in the first second, save it as `media/demo.mp4`, set `"demo_video"`, and run `arcade media preview`. That makes `media/preview.mp4` (the hover preview) and `media/preview.gif`, and sets both fields. Then the vertical demo: a portrait or both game records upright play at 390x844 with touch into `media/vertical/demo.mp4` (set `media_vertical.demo_video`); every game then runs `arcade media vertical` (for a landscape game it makes the whole vertical set from the landscape demo over a blurred fill). **A game with a demo must have its GIF**; `arcade publish` makes it when it's missing and refuses without ffmpeg. Watch the clips and look at the GIF before moving on. Bot-driven footage is fine; say so in `provenance.notes`. Keep recordings outside the game folder, since everything in it is uploaded. Recording needs Playwright and ffmpeg; if they're missing, ask before installing.
|
|
135
137
|
|
|
136
138
|
## Step 7. One dry run, one go, publish
|
|
137
139
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: arcade-publishing
|
|
3
|
-
description: Use when a browser game is ready, or nearly ready, to go live on Evolutionary Arcade (evolutionaryarcade.com) with the `arcade` CLI, or when you are shipping the next version of a game you already published. Covers filling in arcade.json and replacing the CLI's placeholder text, writing an honest Model card (models, harness, tokens, cost, prompts),
|
|
3
|
+
description: Use when a browser game is ready, or nearly ready, to go live on Evolutionary Arcade (evolutionaryarcade.com) with the `arcade` CLI, or when you are shipping the next version of a game you already published. Covers filling in arcade.json and replacing the CLI's placeholder text, writing an honest Model card (models, harness, tokens, cost, prompts), deciding the game's orientation and capturing both media sets (landscape at 1280x720, vertical at 390x844 or the CLI's blurred fill) with Playwright and ffmpeg, reviewing `arcade publish --dry-run` and its Heads up list with the user before `arcade publish --yes`, fixing problems (exit code 2) including a flagged secret, finding generation ids with `arcade info` for `arcade main`, and taking a game down with `arcade unpublish`.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Publishing a game to Evolutionary Arcade
|
|
@@ -16,6 +16,7 @@ Not playable under the arcade's rules yet? Use `arcade-building-games`. First ti
|
|
|
16
16
|
## What good looks like
|
|
17
17
|
|
|
18
18
|
- The thumbnail is a real frame of play, mid-action, 1280x720.
|
|
19
|
+
- Phones get a vertical set that tells the truth about how the game plays: real upright play for a portrait game, or the landscape footage over a blurred fill for a game that only plays sideways.
|
|
19
20
|
- The hover preview comes from this build's demo, and its first seconds show the game doing its best thing. No title screen, no loading.
|
|
20
21
|
- A stranger understands the title and description, and the controls are exact.
|
|
21
22
|
- The Model card is honest: filled in where you know, left out where you don't.
|
|
@@ -35,6 +36,8 @@ Required: `schema`, `slug`, `title`, `description`, `thumbnail`, and at least on
|
|
|
35
36
|
- `profile_saves` (default `false`): the game saves progress to the player's profile with the `arcade-saves.js` helper. Set it only when the game uses the helper; `arcade-building-games` has the rules.
|
|
36
37
|
- `leaderboards` (up to 4, default none): global leaderboards the game posts to with the `arcade-scores.js` helper. Each has an `id`, a `label`, and a `max`, and the dry run lists them with their limits. `arcade-building-games` has the rules.
|
|
37
38
|
- `thumbnail` and `screenshots` (1 to 12) are .png, .jpg, or .webp. `demo_video` and `preview_video` are .mp4 or .webm. `preview_gif` is a .gif cut from the demo, required whenever there's a demo; `arcade media preview` (or `arcade publish`) makes it. Every path is relative to the folder, with no `..`.
|
|
39
|
+
- `orientation` (default `"landscape"`): which way up the game plays on a phone. `"landscape"`, `"portrait"`, or `"both"`. See "Orientation and the vertical set" below.
|
|
40
|
+
- `media_vertical` (optional): the phone (9:16) set, with the same fields as the landscape set: `thumbnail`, `screenshots` (up to 12), `demo_video`, `preview_video`, `preview_gif`. The top-level fields are the landscape set. A `portrait` or `both` game with `touch` must list a vertical thumbnail and at least one vertical screenshot, plus a vertical demo when it has a landscape demo. A landscape game's set is made by `arcade media vertical`.
|
|
38
41
|
- `license` (optional): every game on the arcade is open source under MIT, so leave it out or set it to `"MIT"`. Anything else fails validation. The folder's `LICENSE` names the creator ("Copyright (c) <year> @handle"); if `arcade new` wrote it before you logged in, `arcade publish` fills in the handle. A license file at the top of the folder (`LICENSE`, `LICENSE.md`, `COPYING`, and the like) that isn't MIT stops the publish; other people's licenses go in `licenses/<name>/` or beside their code.
|
|
39
42
|
- `lineage`: the CLI writes it. Don't edit it.
|
|
40
43
|
- `provenance`: the Model card.
|
|
@@ -120,17 +123,52 @@ ffmpeg -ss 4 -i ../rec/<file>.webm -t 24 -an -vf scale=1280:720 \
|
|
|
120
123
|
- **Budget:** the whole upload must stay under 50 MB and 800 files. The thumbnail and each screenshot must be at most 5 MB, and the demo, preview, and any other file at most 25 MB. A 24-second demo at CRF 23 is about 8 MB.
|
|
121
124
|
- **Look at every file** before you publish. For video, a contact sheet is quick: `ffmpeg -i media/demo.mp4 -vf fps=1/3,scale=320:-1,tile=4x3 -frames:v 1 ../rec/sheet.png` (one image, covering 36 s). If headless WebGL renders black, run headed or pass GPU flags to Chromium (on macOS the seeds used `--use-angle=metal --ignore-gpu-blocklist`).
|
|
122
125
|
|
|
126
|
+
## Orientation and the vertical set
|
|
127
|
+
|
|
128
|
+
Phones are a big share of players, and a 16:9 card is a sliver on a phone held upright. So every game ships two media sets: landscape (the top-level fields) and vertical (`media_vertical`). The site shows phones the vertical set. One size doesn't fit all, and the vertical set has to be honest about how the game plays.
|
|
129
|
+
|
|
130
|
+
**Decide the orientation by playing it on a phone.** Run `arcade dev`, open it in Playwright at 390x844 with touch emulation (or on a real phone), and play:
|
|
131
|
+
- `"portrait"`: it plays upright and was built for it (a tall playfield, thumb controls at the bottom).
|
|
132
|
+
- `"both"`: it lays out and plays well either way.
|
|
133
|
+
- `"landscape"`: it needs the phone sideways. That's fine. Don't fake an upright mode.
|
|
134
|
+
A game without touch controls is `"landscape"` unless you add them.
|
|
135
|
+
|
|
136
|
+
**Capture both sets in one pass.** Same build, same `arcade dev` server, two contexts:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
// Landscape: as above, 1280x720.
|
|
140
|
+
// Vertical (portrait and both games): a phone, with touch.
|
|
141
|
+
const phone = await browser.newContext({
|
|
142
|
+
viewport: { width: 390, height: 844 },
|
|
143
|
+
deviceScaleFactor: 3, // screenshots come out 1170x2532
|
|
144
|
+
isMobile: true,
|
|
145
|
+
hasTouch: true,
|
|
146
|
+
recordVideo: { dir: "../rec/vertical", size: { width: 390, height: 844 } },
|
|
147
|
+
});
|
|
148
|
+
const p = await phone.newPage();
|
|
149
|
+
await p.goto("http://localhost:5173/?autopilot");
|
|
150
|
+
await p.tap("canvas"); // a tap, not a click
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Encode the vertical demo at 1080 wide, keeping the recording's own shape (a 390x844 phone is taller than 9:16, so never force 1920 high or it squashes): `ffmpeg -ss 4 -i ../rec/vertical/<file>.webm -t 24 -an -vf scale=1080:-2 -c:v libx264 -pix_fmt yuv420p -crf 23 -movflags +faststart media/vertical/demo.mp4`.
|
|
154
|
+
|
|
155
|
+
**Choose the vertical treatment by orientation:**
|
|
156
|
+
- **portrait or both:** real upright play. Save the vertical thumbnail and 2 to 4 screenshots in `media/vertical/`, the demo as `media/vertical/demo.mp4`, list them under `media_vertical`, then run `arcade media vertical`: it cuts the vertical hover preview (480x854) and GIF from your vertical demo. For `both`, still capture the landscape set at 1280x720; desktop shows it.
|
|
157
|
+
- **landscape:** capture only the landscape set, then run `arcade media vertical`. It centers each landscape image and the demo at full width over a blurred, darkened copy of itself at 1080x1920, writes `media/vertical/` (thumbnail, screenshots, demo, preview, GIF), and sets `media_vertical`. `arcade publish` does this for you when the set is missing. Re-run it after a new landscape demo or thumbnail; the dry run warns when the vertical set is older.
|
|
158
|
+
|
|
159
|
+
Look at the vertical files too (a contact sheet: `ffmpeg -i media/vertical/demo.mp4 -vf fps=1/3,scale=180:-1,tile=6x2 -frames:v 1 ../rec/vsheet.png`). Upright footage of a game that only plays sideways, or a blurred fill whose middle band is unreadable, is a false promise.
|
|
160
|
+
|
|
123
161
|
## The dry run
|
|
124
162
|
|
|
125
163
|
Run `arcade publish --dry-run` and read all of it. It uploads nothing, but know what it is:
|
|
126
164
|
|
|
127
165
|
- Signed out, it runs only the local checks (arcade.json, file types, license, media, secrets), prints any Heads up, and exits 1 asking you to sign in. The full dry run needs `arcade login` and a network connection, because the arcade checks the upload before anything is listed. Problems the arcade finds (a file over 25 MB, too many files, a slug that's taken, a stale base) exit before the file list prints.
|
|
128
166
|
- Every problem it exits 2 on says what to produce: a missing thumbnail names the size and path to capture, a missing arcade.json lists the fields to write.
|
|
129
|
-
- It may make `media/preview.mp4` and `media/preview.gif` and write `preview_video` and `preview_gif` into arcade.json
|
|
167
|
+
- It may make `media/preview.mp4` and `media/preview.gif` and write `preview_video` and `preview_gif` into arcade.json, and, for a landscape game, the vertical set in `media/vertical/` and `media_vertical`.
|
|
130
168
|
- It lists the first 40 files, then "…and N more", but files inside hidden folders (like `.claude/`) are always listed. New files are tagged `new`. Review the whole tree yourself: `find . -type f -not -path './node_modules/*' -not -path './.git/*'`.
|
|
131
169
|
- It follows symlinks only when they point inside the folder. Anything else is listed as skipped, and never uploaded.
|
|
132
170
|
- It says "Published games are open source under MIT" under the action line. Anything bundled from others keeps its own license and must be the user's to share.
|
|
133
|
-
- Its **Heads up** list flags likely mistakes without stopping the publish: a leftover `parents/`, `BLEND.md` or `PROMPT.md`, media already on the arcade on a fork, blend, or regen (so probably the parent's), starter text, a placeholder slug, a home-folder path in the prompt or notes, a thumbnail over 500 KB, and no model or prompt on the Model card. Fix each one, or tell the user why it's fine.
|
|
171
|
+
- Its **Heads up** list flags likely mistakes without stopping the publish: a leftover `parents/`, `BLEND.md` or `PROMPT.md`, media already on the arcade on a fork, blend, or regen (so probably the parent's), starter text, a placeholder slug, a home-folder path in the prompt or notes, a thumbnail over 500 KB, no vertical thumbnail, a vertical set older than the landscape media, game code that calls `requestFullscreen` ("the arcade's player owns fullscreen; remove your fullscreen button"), and no model or prompt on the Model card. Fix each one, or tell the user why it's fine.
|
|
134
172
|
- Its Model card shows the model, tokens, cost, time, process, and the prompt's length, not the prompt or notes text. Read those in arcade.json.
|
|
135
173
|
|
|
136
174
|
What to check:
|