@gtanczyk/diorama 0.2.0 → 0.4.0
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/README.md +1 -0
- package/app/src/actions/fix.ts +28 -10
- package/app/src/actions/story.ts +28 -1
- package/app/src/actions/style.ts +3 -2
- package/app/src/commands/backdrop.ts +23 -3
- package/app/src/commands/clip.ts +7 -0
- package/app/src/commands/derive.ts +9 -0
- package/app/src/commands/export.ts +1 -0
- package/app/src/commands/language.ts +2 -1
- package/app/src/commands/layout.ts +9 -4
- package/app/src/commands/masks.ts +2 -1
- package/app/src/commands/paint.ts +15 -7
- package/app/src/commands/play.ts +1 -0
- package/app/src/commands/playthrough.ts +2 -2
- package/app/src/commands/world.ts +9 -6
- package/app/src/play/car.ts +2 -0
- package/app/src/play/geometry.ts +12 -2
- package/app/src/play/parallax.ts +2 -0
- package/app/src/qa/route.ts +12 -2
- package/app/src/scene/cast.ts +4 -2
- package/app/src/scene/critic.ts +3 -2
- package/app/src/scene/derive.ts +46 -1
- package/app/src/scene/layers.ts +52 -0
- package/app/src/scene/layout.ts +13 -5
- package/app/src/scene/paint-layout.ts +2 -1
- package/app/src/scene/paint.ts +15 -8
- package/app/src/scene/photo.ts +25 -2
- package/app/src/scene/status.ts +2 -1
- package/app/src/scene/vehicle.ts +22 -0
- package/app/src/scene/verify.ts +12 -0
- package/app/src/setup/checks.ts +2 -2
- package/app/src/sound/plan.ts +6 -1
- package/app/src/story/engine.ts +9 -4
- package/app/src/story/fixes.ts +11 -2
- package/app/src/studio/agent.ts +21 -8
- package/app/src/studio/repair.ts +1 -1
- package/app/src/world/design.ts +1 -0
- package/app/src/world/drawings.ts +47 -14
- package/app/src/world/schema.ts +5 -0
- package/app/web/living.ts +36 -0
- package/app/web/main.ts +192 -18
- package/docs/releasing.md +22 -0
- package/docs/scenes.md +10 -1
- package/docs/studio.md +3 -1
- package/package.json +2 -2
package/app/src/studio/repair.ts
CHANGED
|
@@ -19,7 +19,7 @@ export function repairPrompt(job: Job): string {
|
|
|
19
19
|
if (job.unplayable?.length) {
|
|
20
20
|
const places = job.unplayable.map((u) => `- ${u.place}: ${u.errors.join('; ') || 'see place_status'}`).join('\n');
|
|
21
21
|
return `[Studio] The build "${job.label}" ended with places that are not playable, and its free fixes did not help:\n${places}\n`
|
|
22
|
-
+ 'Repair them as your brief says: place_status and place_view first, then the playbook\'s fix tools in order,
|
|
22
|
+
+ 'Repair them as your brief says: place_status and place_view first, then the playbook\'s fix tools in order, after three failed fixes of a place take a different path rather than repeating one, '
|
|
23
23
|
+ 'and ask me before the last resort. Then tell me in two sentences what you fixed and what is left for me.' + animation;
|
|
24
24
|
}
|
|
25
25
|
const tail = job.tail.slice(-15).map((l) => ` ${l}`).join('\n');
|
package/app/src/world/design.ts
CHANGED
|
@@ -35,6 +35,7 @@ ${extra ? `${extra}
|
|
|
35
35
|
` : ''}Design ${places} places that tell this idea${fixed ? ', the room included' : ''}. Rules:
|
|
36
36
|
- 3 to 7 things per place, big enough to see and click; no tiny details.
|
|
37
37
|
- Exits are things you can see (door, path, ladder, boat) and every exit has one back.
|
|
38
|
+
- Only when the idea gives the hero a car to drive around in: one place has "kind": "drive", the car itself (its shows: the car at the kerb with the engine running), with one thing, the car (role exit), and an exit through that same thing to every other place it drives to; every one of those places has the car parked in it as a thing (role exit, the same looks) with an exit back to the drive place. The player gets in, picks a destination and gets out there.
|
|
38
39
|
- The world has at least one item to pick up and one npc to talk to.
|
|
39
40
|
- Everything is original: no characters, places or names from existing films, books, games or brands.
|
|
40
41
|
- A place's shows and its props' looks go to a painter whose safety filter refuses single words: calm, plain words (leafless trees, not bare; a swing seat that rests, not hangs on chains; no blood, weapons, graves or bodies).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
// an item, a prop or a place, kept with the world (drawings/) and used by world, paint, cast and icons.
|
|
1
|
+
// Drawings as the world: photos of drawings on paper (a child's, or a grown-up's sketch), each read by Spark into the
|
|
2
|
+
// hero, an NPC, an item, a prop or a place, kept with the world (drawings/) and used by world, paint, cast and icons.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import sharp from 'sharp';
|
|
@@ -17,6 +17,10 @@ export const Drawn = z.object({
|
|
|
17
17
|
kind: z.enum(['hero', 'npc', 'player', 'item', 'prop', 'place']),
|
|
18
18
|
key: Slug,
|
|
19
19
|
looks: z.string().min(3),
|
|
20
|
+
/** Who drew it, as Spark sees it: a grown-up's sketch makes no kids' game and keeps its own medium (absent = a child). */
|
|
21
|
+
by: z.enum(['child', 'grown-up']).optional(),
|
|
22
|
+
/** A grown-up's sketch: its medium and hand, as the world's style ("fountain-pen ink hatching, grey watercolour wash"). */
|
|
23
|
+
medium: z.string().optional(),
|
|
20
24
|
});
|
|
21
25
|
export type Drawn = z.infer<typeof Drawn>;
|
|
22
26
|
|
|
@@ -59,7 +63,24 @@ export function readDrawn(files: WorldFiles): Drawn[] {
|
|
|
59
63
|
return parsed.success ? parsed.data : [];
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/**
|
|
66
|
+
/** True when every drawing is a grown-up's sketch: then the world is no kids' game and keeps the sketches' own medium. */
|
|
67
|
+
export const grownUpDrawings = (drawn: Drawn[]) => drawn.length > 0 && drawn.every((d) => d.by === 'grown-up');
|
|
68
|
+
|
|
69
|
+
/** A world for children: said so, or made from drawings and not every one of them a grown-up's sketch. */
|
|
70
|
+
export const kidsWorld = (files: WorldFiles, world: World) => world.audience === 'kids' || (fs.existsSync(files.path('drawings')) && !grownUpDrawings(readDrawn(files)));
|
|
71
|
+
|
|
72
|
+
/** Whose drawings the painters are shown, by the world's audience. */
|
|
73
|
+
export const drawers = (world: Pick<World, 'audience'>) => world.audience === 'kids'
|
|
74
|
+
? { one: "a child's drawing", many: "children's drawings", who: 'the child who drew it', whoAll: 'the children' }
|
|
75
|
+
: { one: "an artist's sketch", many: "the artist's sketches", who: 'the artist who drew it', whoAll: 'the artist' };
|
|
76
|
+
|
|
77
|
+
/** The style of a world made from grown-ups' sketches: their own medium and line, finished into a game. */
|
|
78
|
+
export function sketchStyle(drawn: Drawn[]): string {
|
|
79
|
+
const media = [...new Set(drawn.map((d) => d.medium?.trim()).filter((m): m is string => !!m))];
|
|
80
|
+
return `the artist's own sketchbook drawings come alive as finished game art: ${media[0] ?? 'confident pen and ink lines with a light wash'}, the same hand, line weight and palette as the sketches, drawn loose but with clear readable shapes, depth and soft light, paper texture showing through the colour`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The drawing a character, item or place was made from, if one was drawn. */
|
|
63
84
|
export function drawingFor(files: WorldFiles, key: string): Buffer | undefined {
|
|
64
85
|
const drawn = readDrawn(files).find((d) => d.key === key);
|
|
65
86
|
return drawn && fs.existsSync(files.path('drawings', drawn.file)) ? fs.readFileSync(files.path('drawings', drawn.file)) : undefined;
|
|
@@ -78,21 +99,25 @@ export function readKinds(files: WorldFiles): Record<string, Kind> {
|
|
|
78
99
|
/** A kids' world looks like this, so a character painted from a drawing before the world exists is the one the game uses. */
|
|
79
100
|
export const KIDS_STYLE = "the children's own drawings come alive: wax crayon and felt-tip texture, solidly coloured, bold simple rounded shapes with thick dark outlines, bright clean colours, soft sunny light, friendly and readable at a glance";
|
|
80
101
|
|
|
81
|
-
const KIND_TEXT = `-
|
|
102
|
+
const KIND_TEXT = `- by: "child" when a young child drew it (crayon, felt-tip, simple flat shapes) or "grown-up" for a sketch by a teen or an adult (confident pen, pencil, ink, marker, watercolour or digital lines, perspective, hatching, notes)
|
|
103
|
+
- medium: for a grown-up's sketch, its medium and look in a dozen words, as a painter's style ("fountain-pen ink hatching with a grey and ochre watercolour wash on cream paper"); "" for a child's
|
|
104
|
+
- kind: "character" (someone: a person, an animal, a creature), "item" (a small thing to pick up and carry), "prop" (a big thing that stays in a place) or "place" (a whole scene: a landscape, a room, a house with its surroundings)
|
|
82
105
|
- key: a kebab-case English name for it ("green-dragon", "blue-hat", "mushroom-forest")
|
|
83
|
-
- looks: what
|
|
106
|
+
- looks: what was drawn, as a description a painter can follow: shape, every colour, clothes, face, what it holds. Describe the drawing kindly as the real thing it shows (a "green dragon with orange wings and a round light-green belly", not "scribbles"). For a character or a thing, only it, not the sun, sky or ground drawn around it. For a place: what it shows front to back.
|
|
84
107
|
Ignore any name written on the paper, the paper itself and the table.`;
|
|
85
108
|
|
|
86
109
|
/** Spark says what one drawing is, as soon as its photo arrives (no story needed yet). */
|
|
87
110
|
export async function readDrawing(memo: Memo, image: Buffer, file: string, taken: string[] = []): Promise<Drawn> {
|
|
88
|
-
const prompt = `
|
|
111
|
+
const prompt = `Someone drew this picture for a 2D point-and-click adventure game: a child (often 5 or 6, for a game the class makes together) or a grown-up sketching their own world. Say who drew it and what it is in the game:
|
|
89
112
|
${KIND_TEXT}${taken.length ? `\nKeys already used by other drawings: ${taken.join(', ')}. Use one of them only when this drawing shows that same thing again.` : ''}
|
|
90
113
|
|
|
91
|
-
Reply with JSON only: {"kind": "...", "key": "...", "looks": "..."}`;
|
|
114
|
+
Reply with JSON only: {"by": "child | grown-up", "medium": "...", "kind": "...", "key": "...", "looks": "..."}`;
|
|
92
115
|
let ask = prompt;
|
|
93
116
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
94
|
-
const answer = await askSparkJson<{ kind?: string; key?: string; looks?: string }>(memo, { label: `drawing:${file}#${attempt}`, prompt: ask, images: [image], personal: true });
|
|
95
|
-
const
|
|
117
|
+
const answer = await askSparkJson<{ by?: string; medium?: string; kind?: string; key?: string; looks?: string }>(memo, { label: `drawing:${file}#${attempt}`, prompt: ask, images: [image], personal: true });
|
|
118
|
+
const by = answer?.by === 'grown-up' || answer?.by === 'adult' ? 'grown-up' as const : 'child' as const;
|
|
119
|
+
const medium = by === 'grown-up' && answer?.medium?.trim() ? answer.medium.trim() : undefined;
|
|
120
|
+
const parsed = Drawn.safeParse({ file, kind: answer?.kind === 'character' ? 'npc' : answer?.kind, key: answer?.key, looks: answer?.looks, by, ...(medium && { medium }) });
|
|
96
121
|
if (parsed.success) return parsed.data;
|
|
97
122
|
ask = `${prompt}\n\nYour last answer was:\n${JSON.stringify(answer)}\n\nFix: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`;
|
|
98
123
|
}
|
|
@@ -132,7 +157,7 @@ export function fitPlaces(drawn: Drawn[], places: number): Drawn[] {
|
|
|
132
157
|
let kept = 0;
|
|
133
158
|
return drawn.map((d) => {
|
|
134
159
|
if (d.kind !== 'place' || ++kept <= places) return d;
|
|
135
|
-
return { ...d, kind: 'prop', looks: `${d.looks} (
|
|
160
|
+
return { ...d, kind: 'prop', looks: `${d.looks} (drawn as a whole place; here it is one big thing in a place: a picture, a model, a view through a window)` };
|
|
136
161
|
});
|
|
137
162
|
}
|
|
138
163
|
|
|
@@ -156,6 +181,7 @@ export function players(files: WorldFiles): Array<{ key: string; looks: string }
|
|
|
156
181
|
|
|
157
182
|
/** What the world designer is told: every drawing is in the world, under its key, as drawn. */
|
|
158
183
|
export function drawingsBrief(drawn: Drawn[], style = KIDS_STYLE, chooseHero = false): string {
|
|
184
|
+
const who = grownUpDrawings(drawn) ? 'The artist sketched' : 'The children drew';
|
|
159
185
|
const once = [...new Map(drawn.map((d) => [d.key, d])).values()];
|
|
160
186
|
const line = (d: Drawn) => chooseHero && (d.kind === 'hero' || d.kind === 'npc')
|
|
161
187
|
? `- a character, key "${d.key}": ${d.looks} (hero or npc according to the creator's idea and clarifications)` : ({
|
|
@@ -166,7 +192,7 @@ export function drawingsBrief(drawn: Drawn[], style = KIDS_STYLE, chooseHero = f
|
|
|
166
192
|
place: `- a place, key "${d.key}": ${d.looks}`,
|
|
167
193
|
player: '',
|
|
168
194
|
})[d.kind];
|
|
169
|
-
return
|
|
195
|
+
return `${who} these, and every one of them must be in the world, under exactly this key, looking as drawn:
|
|
170
196
|
${once.filter((d) => d.kind !== 'player').map(line).join('\n')}
|
|
171
197
|
${chooseHero ? 'Choose exactly one of these drawn characters as the hero according to the creator\'s idea and answers; the other characters are NPCs. Do not choose the first drawing just because it came first.' : ''}
|
|
172
198
|
The style is exactly: "${style}".`;
|
|
@@ -177,17 +203,24 @@ export function drawnIssues(world: World, drawn: Drawn[]): string[] {
|
|
|
177
203
|
const things = new Map(world.places.flatMap((p) => p.things.map((t) => [t.key, t.role] as const)));
|
|
178
204
|
const places = new Set(world.places.map((p) => p.key));
|
|
179
205
|
return [...new Map(drawn.map((d) => [d.key, d])).values()].flatMap((d) => {
|
|
180
|
-
if (d.kind === 'hero') return world.hero.key === d.key ? [] : [`the hero's key must be "${d.key}" (
|
|
206
|
+
if (d.kind === 'hero') return world.hero.key === d.key ? [] : [`the hero's key must be "${d.key}" (the hero was drawn)`];
|
|
181
207
|
if (d.kind === 'player') return [];
|
|
182
|
-
if (d.kind === 'place') return places.has(d.key) ? [] : [`
|
|
208
|
+
if (d.kind === 'place') return places.has(d.key) ? [] : [`the place "${d.key}" was drawn: it must be one of the places, with that key`];
|
|
183
209
|
const role = things.get(d.key);
|
|
184
|
-
if (!role) return [`
|
|
210
|
+
if (!role) return [`"${d.key}" was drawn: it must be a thing with role "${d.kind}" in one of the places`];
|
|
185
211
|
// A drawn house or cave may well be the way into the next place.
|
|
186
212
|
if (role === d.kind || (d.kind === 'prop' && role === 'exit')) return [];
|
|
187
213
|
return [`"${d.key}" was drawn as ${d.kind === 'item' ? 'an item' : `a ${d.kind}`}: its role must be "${d.kind}"`];
|
|
188
214
|
});
|
|
189
215
|
}
|
|
190
216
|
|
|
217
|
+
/** A grown-up's sketch of the whole place: drawn in perspective, it sets the camera Muse paints with, so the
|
|
218
|
+
* layout starts from it like an approved concept (a child's flat drawing leaves the composition to the layout). */
|
|
219
|
+
export function placeSketch(files: WorldFiles, key: string): Buffer | undefined {
|
|
220
|
+
const drawn = readDrawn(files).find((d) => d.key === key && d.kind === 'place' && d.by === 'grown-up');
|
|
221
|
+
return drawn ? drawingFor(files, key) : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
191
224
|
/** The drawings a place's painting follows: the place itself first, then what was drawn of the props and items in it (three at most). */
|
|
192
225
|
export function drawingsIn(files: WorldFiles, place: Place): Array<{ key: string; image: Buffer }> {
|
|
193
226
|
const keys = [place.key, ...place.things.filter((t) => t.role !== 'npc').map((t) => t.key)];
|
package/app/src/world/schema.ts
CHANGED
|
@@ -11,6 +11,11 @@ export const Thing = z.object({
|
|
|
11
11
|
|
|
12
12
|
export const Place = z.object({
|
|
13
13
|
key: Slug,
|
|
14
|
+
/**
|
|
15
|
+
* "drive": the hero rides along a road, seen from the side. The road is the floor, the scenery streams past in
|
|
16
|
+
* depth bands that loop, the hero sits in the car (unseen) and things are used where they are, without walking.
|
|
17
|
+
*/
|
|
18
|
+
kind: z.enum(['drive']).optional(),
|
|
14
19
|
shows: z.string().min(10),
|
|
15
20
|
mood: z.string().min(3),
|
|
16
21
|
things: z.array(Thing).min(1).max(10),
|
package/app/web/living.ts
CHANGED
|
@@ -402,3 +402,39 @@ export class Clouds {
|
|
|
402
402
|
ctx.drawImage(canvas, this.box[0] + sx, this.box[1] + sy);
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Small things painted falling (code rain, snow, petals): the glyphs cut out of the backdrop fall in columns,
|
|
408
|
+
* each column at its own pace, wrapping round inside their painted region (a soft mask) so they keep falling.
|
|
409
|
+
*/
|
|
410
|
+
export class Stream {
|
|
411
|
+
private readonly canvas: HTMLCanvasElement;
|
|
412
|
+
/** Column width, px: about one glyph, so a letter falls whole. */
|
|
413
|
+
private static readonly COLUMN = 22;
|
|
414
|
+
|
|
415
|
+
constructor(readonly img: HTMLImageElement, readonly region: HTMLImageElement, readonly box: Box, readonly k: number) {
|
|
416
|
+
this.canvas = canvasOf(box[2] - box[0], box[3] - box[1]);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
draw(ctx: CanvasRenderingContext2D, t: number, shift: Shift) {
|
|
420
|
+
const { img, region, canvas } = this;
|
|
421
|
+
const [w, h] = [canvas.width, canvas.height];
|
|
422
|
+
const c = canvas.getContext('2d')!;
|
|
423
|
+
c.globalCompositeOperation = 'source-over';
|
|
424
|
+
c.clearRect(0, 0, w, h);
|
|
425
|
+
const cw = Stream.COLUMN;
|
|
426
|
+
for (let x = 0, i = 0; x < w; x += cw, i++) {
|
|
427
|
+
const speed = 28 + 46 * rand(i, 31 + this.k);
|
|
428
|
+
const y = (t * speed + rand(i, 32 + this.k) * h) % h;
|
|
429
|
+
const sw = Math.min(cw, w - x);
|
|
430
|
+
c.drawImage(img, x, 0, sw, h, x, y, sw, h);
|
|
431
|
+
c.drawImage(img, x, 0, sw, h, x, y - h, sw, h);
|
|
432
|
+
}
|
|
433
|
+
// Only inside the painted rain, fading at its edges.
|
|
434
|
+
c.globalCompositeOperation = 'destination-in';
|
|
435
|
+
c.drawImage(region, 0, 0, w, h);
|
|
436
|
+
c.globalCompositeOperation = 'source-over';
|
|
437
|
+
const [sx, sy] = shift(this.box[3]);
|
|
438
|
+
ctx.drawImage(canvas, this.box[0] + sx, this.box[1] + sy);
|
|
439
|
+
}
|
|
440
|
+
}
|
package/app/web/main.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { CYCLE, pose, sheetCell, type PartName, type Rig } from '../src/play/rig
|
|
|
12
12
|
import { actions, activeChanges, apply, hiddenThings, hintFor, holds, lockOn, pickDialogue, pickInteraction, speakerOf, startState, useFor, type Action, type State } from '../src/story/engine.ts';
|
|
13
13
|
import type { Story } from '../src/story/schema.ts';
|
|
14
14
|
import type { Place as PlaceSpec, World } from '../src/world/schema.ts';
|
|
15
|
+
import { CAR } from '../src/play/car.ts';
|
|
15
16
|
import { Mixer, type Listened } from './audio.ts';
|
|
16
17
|
import { PLAYER_COLOURS, readEachPad, readPads, Turns, type PadButton, type PadFrame } from './pad.ts';
|
|
17
18
|
import { Answers, Bag, displayName, fitHud, showEnding, type ItemLook } from './ui.ts';
|
|
@@ -20,7 +21,7 @@ import { spokenLine, type SoundData } from '../src/play/sound.ts';
|
|
|
20
21
|
import type { Figure3D, ModelInfo } from './hero3d.ts';
|
|
21
22
|
import type { Weather } from '../src/play/weather.ts';
|
|
22
23
|
import { WeatherFx } from './weather.ts';
|
|
23
|
-
import { Clouds, Critter, drawSwell, Fall, Surf } from './living.ts';
|
|
24
|
+
import { Clouds, Critter, drawSwell, Fall, Stream, Surf } from './living.ts';
|
|
24
25
|
import { stormOf } from '../src/play/living.ts';
|
|
25
26
|
import type { Reactions } from '../src/play/reactions.ts';
|
|
26
27
|
import { ReactionFx } from './reactions.ts';
|
|
@@ -76,6 +77,7 @@ type Figure = { img: HTMLImageElement; face?: PaintedFace; faceInfo?: FaceDefini
|
|
|
76
77
|
const FLOATS = /\b(boat|rowboat|dinghy|skiff|canoe|kayak|raft|ship|barge|punt|gondola|sampan|junk|ferry|trawler|yacht|sailboat)s?\b/i;
|
|
77
78
|
|
|
78
79
|
/** canvas: the card as the place looks now (story changes on it); base: as painted, whose alpha is the card's shape. */
|
|
80
|
+
|
|
79
81
|
type Cut = { occ: Occluder; canvas: HTMLCanvasElement; base: HTMLCanvasElement; x0: number; y0: number; y: number };
|
|
80
82
|
|
|
81
83
|
type Place = {
|
|
@@ -103,6 +105,8 @@ type Place = {
|
|
|
103
105
|
surf?: Surf;
|
|
104
106
|
/** Painted waterfalls pouring, painted animals swimming and flying, cloud bands drifting (see living.ts). */
|
|
105
107
|
falls?: Fall[];
|
|
108
|
+
/** Small things painted falling (code rain, snow, petals), column by column (see living.ts). */
|
|
109
|
+
streams?: Stream[];
|
|
106
110
|
critters?: Critter[];
|
|
107
111
|
clouds?: Clouds;
|
|
108
112
|
far?: { sky?: HTMLImageElement; flats: Array<{ img: HTMLImageElement; box: [number, number, number, number]; d: number }> };
|
|
@@ -492,12 +496,16 @@ class Game {
|
|
|
492
496
|
}
|
|
493
497
|
|
|
494
498
|
/** The waterfalls, animals and cloud bands a place's layers set moving. */
|
|
495
|
-
private async living(layers: Layers): Promise<Pick<Place, 'falls' | 'critters' | 'clouds'>> {
|
|
499
|
+
private async living(layers: Layers): Promise<Pick<Place, 'falls' | 'streams' | 'critters' | 'clouds'>> {
|
|
496
500
|
const img = (p: string) => loadImage(files(p)).catch(() => undefined);
|
|
497
501
|
const falls = (await Promise.all((layers.falls ?? []).map(async (f) => {
|
|
498
502
|
const i = await img(f.file);
|
|
499
503
|
return i && new Fall(i, f.box);
|
|
500
504
|
}))).filter((f): f is Fall => !!f);
|
|
505
|
+
const streams = (await Promise.all((layers.streams ?? []).map(async (f, k) => {
|
|
506
|
+
const [i, r] = await Promise.all([img(f.file), img(f.region)]);
|
|
507
|
+
return i && r && new Stream(i, r, f.box, k);
|
|
508
|
+
}))).filter((f): f is Stream => !!f);
|
|
501
509
|
const critters = (await Promise.all((layers.critters ?? []).map(async (c, k) => {
|
|
502
510
|
const i = await img(c.file);
|
|
503
511
|
return i && new Critter(i, c.box, c.medium, c.facing, k);
|
|
@@ -508,7 +516,7 @@ class Game {
|
|
|
508
516
|
const [clip, keep, ...bands] = await Promise.all([img(cl.clip), cl.keep ? img(cl.keep) : Promise.resolve(undefined), ...cl.bands.map((b) => img(b.file))]);
|
|
509
517
|
if (clip && bands.every(Boolean)) clouds = new Clouds(cl.bands.map((b, i) => ({ img: bands[i]!, box: b.box, speed: b.speed })), clip, keep, cl.box);
|
|
510
518
|
}
|
|
511
|
-
return { falls, critters, clouds };
|
|
519
|
+
return { falls, streams, critters, clouds };
|
|
512
520
|
}
|
|
513
521
|
|
|
514
522
|
/** A place, loaded once: a second ask while it loads (the preload, an NPC's walk) waits on the same files. */
|
|
@@ -664,7 +672,10 @@ class Game {
|
|
|
664
672
|
[this.hero.x, this.hero.y] = arrive ?? place.g.start;
|
|
665
673
|
// Coming over a bridge or up the steps, the hero walks in from the exit's far end (by the frame's edge) to
|
|
666
674
|
// where one stands at it, instead of popping up in the middle of the place.
|
|
667
|
-
const edge = back && arrive ? this.exitEdge(back.via) : undefined;
|
|
675
|
+
const edge = back && arrive && place.spec.kind !== 'drive' ? this.exitEdge(back.via) : undefined;
|
|
676
|
+
// In a drive the hero is painted at the wheel (the car's card): unseen as a figure, they speak from the car.
|
|
677
|
+
const car = place.spec.kind === 'drive' ? this.carOf(place) : undefined;
|
|
678
|
+
if (car) [this.hero.x, this.hero.y] = [(car.occ.box[0] + car.occ.box[2]) / 2 - (car.occ.box[2] - car.occ.box[0]) * 0.15, car.occ.box[1] + (car.occ.box[3] - car.occ.box[1]) * 0.45];
|
|
668
679
|
if (edge) {
|
|
669
680
|
[this.hero.x, this.hero.y] = edge;
|
|
670
681
|
this.hero.path = this.place.grid.route(edge, arrive!);
|
|
@@ -838,12 +849,16 @@ class Game {
|
|
|
838
849
|
try {
|
|
839
850
|
const placeKey = this.place.key;
|
|
840
851
|
const [sx, sy] = this.approach(hot);
|
|
841
|
-
|
|
852
|
+
// In a drive things are used from the car, where they are.
|
|
853
|
+
if (this.place.spec.kind !== 'drive' && !(await this.walkTo(sx, sy)) && Math.hypot(this.hero.x - sx, this.hero.y - sy) > this.place.g.cell * 2) return;
|
|
842
854
|
if (this.place.key !== placeKey) return;
|
|
843
855
|
const [x0, , x1] = hot.thing.box;
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
856
|
+
// In a drive the hero keeps their eyes on the road.
|
|
857
|
+
if (this.place.spec.kind !== 'drive') {
|
|
858
|
+
this.hero.facing = (hot.npc?.body.x ?? (x0 + x1) / 2) >= this.hero.x ? 1 : -1;
|
|
859
|
+
// Three-quarter towards it, the way a painted figure talks to someone.
|
|
860
|
+
this.hero.yaw = this.hero.facing * Math.PI / 3;
|
|
861
|
+
}
|
|
847
862
|
this.emit('use', { thing: key, verb });
|
|
848
863
|
document.body.classList.add('played');
|
|
849
864
|
await this.act(this.actionFor(key, hot, verb));
|
|
@@ -885,6 +900,8 @@ class Game {
|
|
|
885
900
|
/** What a click on a thing means: the exit, the talk, the held item, or the first verb the story answers. */
|
|
886
901
|
private actionFor(key: string, hot: Hot, verb?: Verb): Action {
|
|
887
902
|
if (verb === 'look') return { kind: 'do', thing: key, verb: 'look' };
|
|
903
|
+
// A car that drives to several places is driven from its board; a click on it looks at it.
|
|
904
|
+
if (this.place.spec.exits.filter((e) => e.via === key).length > 1) return { kind: 'do', thing: key, verb: 'look' };
|
|
888
905
|
if (this.place.spec.exits.some((e) => e.via === key)) return { kind: 'go', via: key };
|
|
889
906
|
const role = hot.spec?.role ?? hot.thing.role;
|
|
890
907
|
// A person talks: in a conversation when the story has one for them now, else in their own talk lines.
|
|
@@ -955,7 +972,7 @@ class Game {
|
|
|
955
972
|
if (nudge && this.hinted?.key === nudge.thing) this.hinted = { key: nudge.thing, until: this.time + 6 };
|
|
956
973
|
if (after.place !== from) {
|
|
957
974
|
// Out the same way: along the bridge or down the steps toward the frame's edge before the picture fades.
|
|
958
|
-
const edge = action.kind === 'go' ? this.exitEdge(action.via) : undefined;
|
|
975
|
+
const edge = action.kind === 'go' && this.place.spec.kind !== 'drive' ? this.exitEdge(action.via) : undefined;
|
|
959
976
|
if (edge) await Promise.race([this.walkTo(...edge), this.wait(2500)]);
|
|
960
977
|
this.emit('exit', { from, to: after.place });
|
|
961
978
|
await this.fadeOut();
|
|
@@ -1274,13 +1291,39 @@ class Game {
|
|
|
1274
1291
|
thingAt(x: number, y: number): Hot | undefined {
|
|
1275
1292
|
const g = this.place.g;
|
|
1276
1293
|
const gone = this.gone();
|
|
1294
|
+
const [sx0, sy0] = [x, y];
|
|
1277
1295
|
for (let i = this.drawn.length - 1; i >= 0; i--) {
|
|
1278
1296
|
const [x0, y0, x1, y1] = this.drawn[i].rect;
|
|
1279
1297
|
// The rect includes the shadow; trim it and the transparent margin at the sides.
|
|
1280
1298
|
const inset = (x1 - x0) * 0.12;
|
|
1281
|
-
if (x >= x0 + inset && x <= x1 - inset && y >= y0 && y <= y1 - 4)
|
|
1299
|
+
if (!(x >= x0 + inset && x <= x1 - inset && y >= y0 && y <= y1 - 4)) continue;
|
|
1300
|
+
// A person's rect is wider than the person: where nobody was drawn over a painted thing (a potion at a
|
|
1301
|
+
// scientist's feet), the thing is what is under the pointer.
|
|
1302
|
+
const under = this.drawnOver(sx0, sy0) === false ? this.paintedAt(sx0, sy0, gone) : undefined;
|
|
1303
|
+
return under?.mask ? under : this.drawn[i].hot;
|
|
1282
1304
|
}
|
|
1283
|
-
|
|
1305
|
+
return this.paintedAt(sx0, sy0, gone);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Whether anyone was drawn over the frame point in the last frame: the canvas there differs from the place as
|
|
1310
|
+
* drawn without anyone (the depth snapshot). Undefined without a snapshot.
|
|
1311
|
+
*/
|
|
1312
|
+
private drawnOver(x: number, y: number): boolean | undefined {
|
|
1313
|
+
if (!this.snapped || !this.snap) return undefined;
|
|
1314
|
+
const { width: W, height: H } = this.place.g.frame;
|
|
1315
|
+
const z = this.zoom();
|
|
1316
|
+
const [cx, cy] = [Math.floor(((x - (this.viewX || W / 2)) * z + W / 2) * (canvas.width / W)), Math.floor(((y - (this.viewY || H / 2)) * z + H / 2) * (canvas.height / H))];
|
|
1317
|
+
if (cx < 0 || cy < 0 || cx >= canvas.width || cy >= canvas.height) return undefined;
|
|
1318
|
+
const a = ctx.getImageData(cx, cy, 1, 1).data;
|
|
1319
|
+
const b = this.snap.getContext('2d')!.getImageData(cx, cy, 1, 1).data;
|
|
1320
|
+
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) > 24;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** The painted thing (not a person) under a frame point, by its own mask where it has one. */
|
|
1324
|
+
private paintedAt(sx0: number, sy0: number, gone: Set<string>): Hot | undefined {
|
|
1325
|
+
const g = this.place.g;
|
|
1326
|
+
let [x, y] = [sx0, sy0];
|
|
1284
1327
|
for (const hot of this.hots()) {
|
|
1285
1328
|
if (this.drawn.some((d) => d.hot === hot)) continue;
|
|
1286
1329
|
if (gone.has(hot.thing.key)) continue;
|
|
@@ -1444,6 +1487,12 @@ class Game {
|
|
|
1444
1487
|
return this.shift(y);
|
|
1445
1488
|
}
|
|
1446
1489
|
|
|
1490
|
+
/** The car of a drive place: the card named as a car, else the biggest card standing on the road. */
|
|
1491
|
+
private carOf(place: Place): Cut | undefined {
|
|
1492
|
+
const named = (c: Cut) => { const k = c.occ.thing ?? c.occ.key; return `${k} ${place.spec.things.find((t) => t.key === k)?.looks ?? ''}`; };
|
|
1493
|
+
return place.cuts.find((c) => CAR.test(named(c))) ?? [...place.cuts].sort((a, b) => b.canvas.width * b.canvas.height - a.canvas.width * a.canvas.height)[0];
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1447
1496
|
/** Draws the card's columns [xa, xb) (frame x) in strips, each slid by the floor under it. */
|
|
1448
1497
|
private drawCard(cut: Cut, xa = cut.x0, xb = cut.x0 + cut.canvas.width, ya = cut.y0, yb = cut.y0 + cut.canvas.height) {
|
|
1449
1498
|
ctx.save();
|
|
@@ -1459,6 +1508,17 @@ class Game {
|
|
|
1459
1508
|
* card (its outline, its story change) goes through here, so it moves exactly as the card does.
|
|
1460
1509
|
*/
|
|
1461
1510
|
private poseCard(cut: Cut) {
|
|
1511
|
+
if (this.place.spec.kind === 'drive' && cut === this.carOf(this.place) && !params.has('still')) {
|
|
1512
|
+
// The engine idles: the body trembles on its springs, harder while it revs to leave.
|
|
1513
|
+
const t = this.time;
|
|
1514
|
+
const rev = this.revving > t ? 3 : 1;
|
|
1515
|
+
const shake = (Math.sin(t * 61) * 0.5 + Math.sin(t * 37 + 1) * 0.35) * rev;
|
|
1516
|
+
const [cx, cy] = [(cut.occ.box[0] + cut.occ.box[2]) / 2, cut.occ.box[3]];
|
|
1517
|
+
ctx.translate(cx, cy + shake);
|
|
1518
|
+
ctx.rotate(Math.sin(t * 23) * 0.0012 * rev);
|
|
1519
|
+
ctx.translate(-cx, -cy);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1462
1522
|
const float = this.afloat(cut);
|
|
1463
1523
|
if (float && !params.has('still')) {
|
|
1464
1524
|
const t = this.time + (hashSeed(cut.occ.key) % 1000) / 160;
|
|
@@ -1907,7 +1967,9 @@ class Game {
|
|
|
1907
1967
|
const gone = this.gone();
|
|
1908
1968
|
this.recut();
|
|
1909
1969
|
const npcs = this.hots().filter((h) => (h.spec?.role ?? h.thing.role) === 'npc' && !h.mask);
|
|
1910
|
-
|
|
1970
|
+
// In a drive the hero is the one painted at the wheel.
|
|
1971
|
+
const riding = this.place.spec.kind === 'drive';
|
|
1972
|
+
const people: Who[] = [...npcs.map((h) => ({ npc: h, y: h.npc?.body.y ?? h.thing.box[3] })), ...(riding ? [] : [this.hero, ...this.party]).map((body) => ({ body, y: body.y }))].sort((a, b) => a.y - b.y);
|
|
1911
1973
|
const fire = this.fireOf(gone);
|
|
1912
1974
|
this.climbs.length = 0;
|
|
1913
1975
|
if (p && backdrop) {
|
|
@@ -1963,6 +2025,7 @@ class Game {
|
|
|
1963
2025
|
this.place.clouds?.draw(ctx, this.time, this.place.weather?.w.wind ?? 0, (y) => shiftAt(g, p, y));
|
|
1964
2026
|
this.drawWater(p);
|
|
1965
2027
|
for (const f of this.place.falls ?? []) f.draw(ctx, this.time, (y) => shiftAt(g, p, y));
|
|
2028
|
+
for (const f of this.place.streams ?? []) f.draw(ctx, this.time, (y) => shiftAt(g, p, y));
|
|
1966
2029
|
for (const c of this.place.critters ?? []) c.draw(ctx, this.time, (y) => shiftAt(g, p, y));
|
|
1967
2030
|
this.drawFloats(p);
|
|
1968
2031
|
this.drawSmoke(p);
|
|
@@ -2019,6 +2082,7 @@ class Game {
|
|
|
2019
2082
|
this.drawClimbs();
|
|
2020
2083
|
}
|
|
2021
2084
|
this.place.glows?.draw(ctx, (y) => this.shift(y));
|
|
2085
|
+
if (this.place.spec.kind === 'drive') this.drawIdle();
|
|
2022
2086
|
this.place.weather?.drawDrips(ctx, (y) => this.shift(y));
|
|
2023
2087
|
this.drawFocus();
|
|
2024
2088
|
// The place as drawn so far, without anyone: what the depth map puts back over a person (occludeDepth).
|
|
@@ -2659,6 +2723,108 @@ class Game {
|
|
|
2659
2723
|
}
|
|
2660
2724
|
|
|
2661
2725
|
/** Shadows that land on things standing behind their person (a tent wall): drawn over the cards. */
|
|
2726
|
+
/** Until when a drive's engine revs (it shakes harder) before it leaves. */
|
|
2727
|
+
revving = 0;
|
|
2728
|
+
/** The destination board of a drive place, as drawn this frame: each place it leads to and its box. */
|
|
2729
|
+
private board: Array<{ to: string; via: string; box: [number, number, number, number] }> = [];
|
|
2730
|
+
/** The destination under the pointer. */
|
|
2731
|
+
boardHover: string | undefined;
|
|
2732
|
+
|
|
2733
|
+
/** Where a drive place's car leads: every exit through a thing that leads to more than one place. */
|
|
2734
|
+
destinations(): Array<{ to: string; via: string }> {
|
|
2735
|
+
if (this.place?.spec.kind !== 'drive') return [];
|
|
2736
|
+
const exits = this.place.spec.exits;
|
|
2737
|
+
return exits.filter((e) => exits.filter((o) => o.via === e.via).length > 1).map((e) => ({ to: e.to, via: e.via }));
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
destinationAt(x: number, y: number): string | undefined {
|
|
2741
|
+
return this.board.find((b) => x >= b.box[0] && x <= b.box[2] && y >= b.box[1] && y <= b.box[3])?.to;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
/** The middle of a destination's row on the board, in page pixels (for scripts and recorders). */
|
|
2745
|
+
destinationPoint(to: string): { x: number; y: number } | null {
|
|
2746
|
+
const b = this.board.find((d) => d.to === to);
|
|
2747
|
+
if (!b) return null;
|
|
2748
|
+
const z = this.zoom();
|
|
2749
|
+
const { width: W, height: H } = this.place.g.frame;
|
|
2750
|
+
const [fx, fy] = [((b.box[0] + b.box[2]) / 2 - (this.viewX || W / 2)) * z + W / 2, ((b.box[1] + b.box[3]) / 2 - (this.viewY || H / 2)) * z + H / 2];
|
|
2751
|
+
const r = canvas.getBoundingClientRect();
|
|
2752
|
+
return { x: r.left + (fx * r.width) / W, y: r.top + (fy * r.height) / H };
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
/** Drives off to a destination: the engine revs, the picture fades, the car is parked there and the hero gets out. */
|
|
2756
|
+
async driveTo(to: string) {
|
|
2757
|
+
const d = this.destinations().find((e) => e.to === to);
|
|
2758
|
+
if (!d || this.busy) return;
|
|
2759
|
+
this.busy = true;
|
|
2760
|
+
try {
|
|
2761
|
+
this.emit('use', { thing: d.via, verb: 'go' });
|
|
2762
|
+
this.revving = this.time + 1;
|
|
2763
|
+
await this.wait(800);
|
|
2764
|
+
await this.act({ kind: 'go', via: d.via, to });
|
|
2765
|
+
} finally {
|
|
2766
|
+
this.busy = false;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
/** A drive place's engine running (exhaust puffing from behind the car) and its board of destinations. */
|
|
2771
|
+
private drawIdle() {
|
|
2772
|
+
const car = this.carOf(this.place);
|
|
2773
|
+
const t = this.time;
|
|
2774
|
+
if (car && !params.has('still')) {
|
|
2775
|
+
const [x0, y0, x1, y1] = car.occ.box;
|
|
2776
|
+
const [w, h] = [x1 - x0, y1 - y0];
|
|
2777
|
+
const rev = this.revving > t ? 2.2 : 1;
|
|
2778
|
+
ctx.save();
|
|
2779
|
+
for (let i = 0; i < 10; i++) {
|
|
2780
|
+
const age = (t * 0.8 * rev + i / 10) % 1;
|
|
2781
|
+
const r = w * (0.02 + age * 0.08);
|
|
2782
|
+
ctx.fillStyle = `rgba(210,210,215,${(0.32 * (1 - age) * Math.min(1, age * 6)).toFixed(3)})`;
|
|
2783
|
+
ctx.beginPath();
|
|
2784
|
+
ctx.arc(x0 + w * 0.12 - age * w * 0.22 + Math.sin(t * 2 + i) * 4, y1 - h * 0.12 - age * h * 0.35, r, 0, Math.PI * 2);
|
|
2785
|
+
ctx.fill();
|
|
2786
|
+
}
|
|
2787
|
+
ctx.restore();
|
|
2788
|
+
}
|
|
2789
|
+
const where = this.destinations();
|
|
2790
|
+
this.board = [];
|
|
2791
|
+
if (!where.length) return;
|
|
2792
|
+
const W = this.place.g.frame.width;
|
|
2793
|
+
// With ?fill=1 the painting can run past the page's edges: the board sits at the page's own top right corner.
|
|
2794
|
+
const r = canvas.getBoundingClientRect();
|
|
2795
|
+
const [visRight, visTop] = fill ? this.unzoom(((Math.min(innerWidth, r.right) - r.left) / r.width) * W, (Math.max(0, -r.top) / r.height) * this.place.g.frame.height) : [W, 0];
|
|
2796
|
+
const [rowH, bw, x1, top] = [52, 330, Math.min(W, visRight) - 28, Math.max(0, visTop) + 36];
|
|
2797
|
+
const x0 = x1 - bw;
|
|
2798
|
+
ctx.save();
|
|
2799
|
+
ctx.fillStyle = 'rgba(12,10,18,0.78)';
|
|
2800
|
+
ctx.strokeStyle = 'rgba(255,60,170,0.9)';
|
|
2801
|
+
ctx.lineWidth = 2;
|
|
2802
|
+
ctx.beginPath();
|
|
2803
|
+
ctx.roundRect(x0, top, bw, 54 + where.length * rowH, 12);
|
|
2804
|
+
ctx.fill();
|
|
2805
|
+
ctx.stroke();
|
|
2806
|
+
ctx.fillStyle = '#ffd34d';
|
|
2807
|
+
ctx.font = '700 22px system-ui, sans-serif';
|
|
2808
|
+
ctx.textBaseline = 'middle';
|
|
2809
|
+
ctx.fillText('Where to?', x0 + 18, top + 28);
|
|
2810
|
+
where.forEach((d, i) => {
|
|
2811
|
+
const y = top + 54 + i * rowH;
|
|
2812
|
+
const box: [number, number, number, number] = [x0 + 8, y + 3, x1 - 8, y + rowH - 3];
|
|
2813
|
+
this.board.push({ ...d, box });
|
|
2814
|
+
const lit = this.boardHover === d.to;
|
|
2815
|
+
ctx.fillStyle = lit ? 'rgba(255,60,170,0.85)' : 'rgba(255,255,255,0.07)';
|
|
2816
|
+
ctx.beginPath();
|
|
2817
|
+
ctx.roundRect(box[0], box[1], box[2] - box[0], box[3] - box[1], 8);
|
|
2818
|
+
ctx.fill();
|
|
2819
|
+
ctx.fillStyle = lit ? '#fff' : '#e8e4f0';
|
|
2820
|
+
ctx.font = '600 21px system-ui, sans-serif';
|
|
2821
|
+
ctx.fillText(displayName(d.to, this.data.world.names), box[0] + 14, (box[1] + box[3]) / 2);
|
|
2822
|
+
ctx.fillStyle = '#5ee6ff';
|
|
2823
|
+
ctx.fillText('›', box[2] - 26, (box[1] + box[3]) / 2);
|
|
2824
|
+
});
|
|
2825
|
+
ctx.restore();
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2662
2828
|
private climbs: Array<{ canvas: HTMLCanvasElement; box: [number, number, number, number]; dark: number; blur: string; shift: [number, number] }> = [];
|
|
2663
2829
|
private climbPool: HTMLCanvasElement[] = [];
|
|
2664
2830
|
private climbMask = document.createElement('canvas');
|
|
@@ -3065,7 +3231,9 @@ class Game {
|
|
|
3065
3231
|
const y0 = Math.max(top, cut.y0);
|
|
3066
3232
|
const y1 = Math.min(bottom, cut.y0 + cut.canvas.height);
|
|
3067
3233
|
if (y1 <= y0) continue;
|
|
3068
|
-
|
|
3234
|
+
// Where the feet are, as seen from the card: a figure's box need not be centred on them.
|
|
3235
|
+
const feet = (feetX ?? (rect[0] + rect[2]) / 2) - sx;
|
|
3236
|
+
for (const [a, b] of hiddenRuns(cut.occ, left, right, feetY, support, feet)) {
|
|
3069
3237
|
const xa = Math.max(a, cut.x0);
|
|
3070
3238
|
const xb = Math.min(b, cut.x0 + cut.canvas.width);
|
|
3071
3239
|
if (xb > xa) this.drawCard(cut, xa, xb, y0, y1);
|
|
@@ -3089,9 +3257,12 @@ class Game {
|
|
|
3089
3257
|
const width = Math.max(...lines.map((l) => ctx.measureText(l).width)) + 40;
|
|
3090
3258
|
const height = lines.length * 28 + (name ? 30 : 22);
|
|
3091
3259
|
const head = (left + right) / 2;
|
|
3092
|
-
|
|
3260
|
+
// With ?fill=1 the canvas can run past the page's edges: the bubble stays on the page's own part of it.
|
|
3261
|
+
const r = canvas.getBoundingClientRect();
|
|
3262
|
+
const [visL, visR, visT] = fill && r.width ? [(Math.max(0, -r.left) / r.width) * canvas.width, ((Math.min(innerWidth, r.right) - r.left) / r.width) * canvas.width, (Math.max(0, -r.top) / r.height) * canvas.height] : [0, canvas.width, 0];
|
|
3263
|
+
const cx = Math.min(visR - width / 2 - 12, Math.max(visL + width / 2 + 12, head));
|
|
3093
3264
|
const tail = 22;
|
|
3094
|
-
const y = Math.max(name ? 24 : 10, top - height - tail - 6);
|
|
3265
|
+
const y = Math.max(visT + (name ? 24 : 10), top - height - tail - 6);
|
|
3095
3266
|
const ink = '#2b2118';
|
|
3096
3267
|
ctx.lineJoin = 'round';
|
|
3097
3268
|
ctx.lineWidth = 2.5;
|
|
@@ -3369,10 +3540,11 @@ canvas.addEventListener('pointermove', (e) => {
|
|
|
3369
3540
|
usePad(false);
|
|
3370
3541
|
const r = canvas.getBoundingClientRect();
|
|
3371
3542
|
pointer = { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };
|
|
3372
|
-
game.
|
|
3543
|
+
game.boardHover = game.destinationAt(...toFrame(e));
|
|
3544
|
+
game.hover = game.boardHover ? undefined : game.thingAt(...toFrame(e));
|
|
3373
3545
|
// Off the floor (a wall, the sky, the water) the cursor says no, so nobody clicks blind.
|
|
3374
|
-
const off = !game.hover && !game.place.grid.walkable(...game.floorAt(...toFrame(e)));
|
|
3375
|
-
canvas.style.cursor = hideCursor || game.holding ? 'none' : game.hover ? 'pointer' : off ? 'not-allowed' : 'default';
|
|
3546
|
+
const off = !game.hover && !game.boardHover && !game.place.grid.walkable(...game.floorAt(...toFrame(e)));
|
|
3547
|
+
canvas.style.cursor = hideCursor || game.holding ? 'none' : game.hover || game.boardHover ? 'pointer' : off ? 'not-allowed' : 'default';
|
|
3376
3548
|
game.bag.point(e.clientX, e.clientY, game.hover?.thing.key.replace(/-/g, ' '));
|
|
3377
3549
|
document.getElementById('hover')!.textContent = game.hover ? game.hover.thing.key.replace(/-/g, ' ') : '';
|
|
3378
3550
|
});
|
|
@@ -3404,6 +3576,8 @@ canvas.addEventListener('click', (e) => {
|
|
|
3404
3576
|
if (e.detail >= 2 && game.hero.path.length) return void (game.hero.run = true);
|
|
3405
3577
|
game.hero.run = false;
|
|
3406
3578
|
const [x, y] = toFrame(e);
|
|
3579
|
+
const to = game.destinationAt(x, y);
|
|
3580
|
+
if (to) return void game.driveTo(to);
|
|
3407
3581
|
const hot = game.thingAt(x, y);
|
|
3408
3582
|
if (hot) void game.use(hot.thing.key);
|
|
3409
3583
|
else void game.walkTo(...game.floorAt(x, y));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
`CHANGELOG.md` decides every npm release of `@gtanczyk/diorama`; nobody runs `npm version` by hand.
|
|
4
|
+
|
|
5
|
+
1. With a change to shipped code (`app/bin`, `app/src`, `app/studio`, `app/web`), add one line under
|
|
6
|
+
`## Unreleased`, written for someone updating Diorama:
|
|
7
|
+
- `### Breaking`: a command, flag or world file layout changed incompatibly (minor before 1.0, major after)
|
|
8
|
+
- `### Added`: something new you can do (minor)
|
|
9
|
+
- `### Fixed`: something that was wrong now works (patch)
|
|
10
|
+
- `### Internal`: refactors, tests, tooling; never releases
|
|
11
|
+
2. Push to main. `release-pr.yaml` checks the changelog, cuts the next version on branch `release/next`
|
|
12
|
+
and opens (or refreshes) the PR **`release: vX.Y.Z`**. Later pushes with more lines update the same PR.
|
|
13
|
+
3. Merge the PR. `publish.yaml` sees `package.json` change, publishes to npm (trusted publishing, no
|
|
14
|
+
token) and creates the `vX.Y.Z` tag and a GitHub Release with that section as notes.
|
|
15
|
+
|
|
16
|
+
Hold a release by leaving the PR open. Set the repository variable `RELEASE_AUTOMERGE=true` to merge it
|
|
17
|
+
by itself, and the secret `RELEASE_TOKEN` (a PAT) if CI must run on the release PR. A version npm already holds is stepped past by patch and never published twice; Actions → Publish to npm → Run workflow
|
|
18
|
+
publishes the version in `package.json` by hand.
|
|
19
|
+
|
|
20
|
+
Locally: `node app/scripts/release.mjs next` (the next version, or none), `notes 0.2.0`, `check`
|
|
21
|
+
(changelog well formed and agreeing with package.json; `--files a,b` also asks for a changelog line
|
|
22
|
+
when those files ship).
|