@gtanczyk/diorama 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +160 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +267 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +32 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/language.ts +52 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +290 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +161 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +158 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +37 -0
- package/app/src/commands/language.ts +87 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +195 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +100 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +105 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +33 -0
- package/app/src/scene/cast.ts +199 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +109 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +379 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +215 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +419 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +555 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +423 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/language.ts +116 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +679 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1329 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3769 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
package/app/web/ui.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// The play page's HTML layer over the canvas: the courier's bag of painted items, the item held as the
|
|
2
|
+
// cursor, the flight of a picked-up item into the bag, and the conversation's answer cards.
|
|
3
|
+
import { words } from './words.ts';
|
|
4
|
+
|
|
5
|
+
/** What the page knows about an item: its name and its icon (diorama icons), if it has one. */
|
|
6
|
+
export type ItemLook = { name: string; icon?: string };
|
|
7
|
+
|
|
8
|
+
const PICKUP_MS = 900;
|
|
9
|
+
|
|
10
|
+
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, className = '', text = ''): HTMLElementTagNameMap[K] => {
|
|
11
|
+
const e = document.createElement(tag);
|
|
12
|
+
if (className) e.className = className;
|
|
13
|
+
if (text) e.textContent = text;
|
|
14
|
+
return e;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A key like "ornitolog-mila" as a name to show: "Ornitolog Mila". */
|
|
18
|
+
/** A speaker's name: the world's own (world.json names, in its language), else one made from the key. */
|
|
19
|
+
export const displayName = (key: string, names?: Record<string, string>) => names?.[key] ?? key.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
20
|
+
|
|
21
|
+
export class Bag {
|
|
22
|
+
private readonly root = document.getElementById('inventory')!;
|
|
23
|
+
private readonly slots = el('div', 'slots');
|
|
24
|
+
private readonly toast = el('div', 'toast');
|
|
25
|
+
private readonly cursor = el('div', 'held-cursor');
|
|
26
|
+
private readonly cursorImg = el('img');
|
|
27
|
+
private readonly cursorLabel = el('div', 'label');
|
|
28
|
+
/** Items on their way into the bag: their slot stays empty until they land. */
|
|
29
|
+
private flying = new Set<string>();
|
|
30
|
+
private shown: string[] = [];
|
|
31
|
+
private holding: string | undefined;
|
|
32
|
+
|
|
33
|
+
/** `pointer` false (recordings, cursor=0): the held item does not follow a pointer. */
|
|
34
|
+
constructor(private readonly look: (key: string) => ItemLook, private readonly onPick: (key: string) => void, private readonly pointer = true) {
|
|
35
|
+
const bag = el('div', 'bag');
|
|
36
|
+
bag.innerHTML = '<svg width="38" height="38" viewBox="0 0 42 42" aria-hidden="true"><path d="M7 14 h28 v20 a3 3 0 0 1 -3 3 h-22 a3 3 0 0 1 -3 -3 z" fill="#5f8f8a" stroke="#2b2118" stroke-width="2.2"/><path d="M7 14 l4 -7 h20 l4 7" fill="#7aa9a2" stroke="#2b2118" stroke-width="2.2" stroke-linejoin="round"/><rect x="17" y="18" width="8" height="8" rx="1.5" fill="#c9774a" stroke="#2b2118" stroke-width="2"/></svg>';
|
|
37
|
+
this.root.replaceChildren(bag, this.slots);
|
|
38
|
+
this.cursorImg.alt = '';
|
|
39
|
+
this.cursor.append(this.cursorImg, this.cursorLabel);
|
|
40
|
+
this.cursor.hidden = true;
|
|
41
|
+
// Off the page until the pointer first moves.
|
|
42
|
+
this.cursor.style.transform = 'translate(-300px, -300px)';
|
|
43
|
+
document.getElementById('hud')!.append(this.toast);
|
|
44
|
+
document.body.append(this.cursor);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Redraws the slots for the items carried now; `holding` is lifted and becomes the cursor. */
|
|
48
|
+
show(items: string[], holding: string | undefined) {
|
|
49
|
+
this.shown = items;
|
|
50
|
+
this.holding = holding;
|
|
51
|
+
this.root.hidden = items.length === 0;
|
|
52
|
+
this.slots.replaceChildren(...items.map((key) => {
|
|
53
|
+
const item = this.look(key);
|
|
54
|
+
const b = el('button', `slot${key === holding ? ' held' : ''}${this.flying.has(key) ? ' arriving' : ''}`);
|
|
55
|
+
b.dataset.item = key;
|
|
56
|
+
b.title = item.name;
|
|
57
|
+
b.setAttribute('aria-label', item.name);
|
|
58
|
+
if (item.icon) {
|
|
59
|
+
const img = el('img');
|
|
60
|
+
img.src = item.icon;
|
|
61
|
+
img.alt = '';
|
|
62
|
+
b.append(img);
|
|
63
|
+
} else b.append(el('span', 'name', item.name));
|
|
64
|
+
b.onclick = (e) => {
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
this.onPick(key);
|
|
67
|
+
};
|
|
68
|
+
return b;
|
|
69
|
+
}));
|
|
70
|
+
const held = holding ? this.look(holding) : undefined;
|
|
71
|
+
this.cursor.hidden = !held || !this.pointer;
|
|
72
|
+
if (held) {
|
|
73
|
+
this.cursorImg.hidden = !held.icon;
|
|
74
|
+
if (held.icon) this.cursorImg.src = held.icon;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Dims the bag while a conversation has the floor. */
|
|
79
|
+
quiet(on: boolean) {
|
|
80
|
+
this.root.classList.toggle('quiet', on);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Moves the held item with the pointer; `over` names what it would be used on. */
|
|
84
|
+
point(x: number, y: number, over: string | undefined) {
|
|
85
|
+
if (!this.holding) return;
|
|
86
|
+
this.cursor.style.transform = `translate(${x}px, ${y}px)`;
|
|
87
|
+
const name = this.look(this.holding).name;
|
|
88
|
+
this.cursorLabel.textContent = over ? words.useOn(name, over) : name;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A new item flies from where it was (a page rect) into its slot, then the slot pops and its name shows. */
|
|
92
|
+
async pickUp(key: string, from: DOMRect | undefined) {
|
|
93
|
+
const item = this.look(key);
|
|
94
|
+
this.flying.add(key);
|
|
95
|
+
this.show(this.shown.includes(key) ? this.shown : [...this.shown, key], this.holding);
|
|
96
|
+
const slot = this.slots.querySelector<HTMLElement>(`[data-item="${CSS.escape(key)}"]`);
|
|
97
|
+
if (slot && from && item.icon) {
|
|
98
|
+
const to = slot.getBoundingClientRect();
|
|
99
|
+
const ghost = el('img', 'flying');
|
|
100
|
+
ghost.src = item.icon;
|
|
101
|
+
document.body.append(ghost);
|
|
102
|
+
// The icon is square and the item fills most of it, so it starts as big as the thing it was; an item found in
|
|
103
|
+
// something big (a fish from the stalls) comes out of its middle at a few slots' size.
|
|
104
|
+
const size = Math.min(Math.max(from.width, from.height) * 1.12, to.width * 2.5);
|
|
105
|
+
const start = { x: from.left + from.width / 2 - size / 2, y: from.top + from.height / 2 - size / 2 };
|
|
106
|
+
const end = { x: to.left + to.width / 2 - size / 2, y: to.top + to.height / 2 - size / 2 };
|
|
107
|
+
const scale = (to.width * 0.8) / size;
|
|
108
|
+
Object.assign(ghost.style, { width: `${size}px`, height: `${size}px`, left: `${start.x}px`, top: `${start.y}px` });
|
|
109
|
+
const lift = { x: (end.x - start.x) * 0.35, y: Math.min(0, end.y - start.y) * 0.3 - 80 };
|
|
110
|
+
await ghost.animate([
|
|
111
|
+
{ transform: 'translate(0, 0) scale(1)', filter: 'drop-shadow(0 0 0 #ffd37a)' },
|
|
112
|
+
{ transform: 'translate(0, -18px) scale(1.08)', filter: 'drop-shadow(0 0 12px #ffd37a)', offset: 0.18 },
|
|
113
|
+
{ transform: `translate(${lift.x}px, ${lift.y}px) scale(${(1 + scale) / 2})`, filter: 'drop-shadow(0 0 10px #ffd37a)', offset: 0.55 },
|
|
114
|
+
{ transform: `translate(${end.x - start.x}px, ${end.y - start.y}px) scale(${scale})`, filter: 'drop-shadow(0 2px 1px #2b211855)' },
|
|
115
|
+
], { duration: PICKUP_MS, easing: 'cubic-bezier(.45,.05,.4,1)' }).finished.catch(() => undefined);
|
|
116
|
+
ghost.remove();
|
|
117
|
+
}
|
|
118
|
+
this.flying.delete(key);
|
|
119
|
+
this.show(this.shown, this.holding);
|
|
120
|
+
this.slots.querySelector(`[data-item="${CSS.escape(key)}"]`)?.animate(
|
|
121
|
+
[{ transform: 'scale(1)' }, { transform: 'scale(1.18)', background: '#ffe7a8' }, { transform: 'scale(1)' }],
|
|
122
|
+
{ duration: 360, easing: 'ease-out' },
|
|
123
|
+
);
|
|
124
|
+
this.toast.textContent = `+ ${item.name}`;
|
|
125
|
+
this.toast.animate([{ opacity: 0, transform: 'translate(-50%, 8px)' }, { opacity: 1, transform: 'translate(-50%, 0)', offset: 0.15 }, { opacity: 1, offset: 0.8 }, { opacity: 0 }], { duration: 1800 });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The hero's answers as numbered cards in a paper panel at the bottom centre; keys 1 to 9 pick them too, and a gamepad
|
|
131
|
+
* moves a lit card and picks it.
|
|
132
|
+
*/
|
|
133
|
+
export class Answers {
|
|
134
|
+
private readonly root = document.getElementById('talk')!;
|
|
135
|
+
private picks: Array<() => void> = [];
|
|
136
|
+
private cards: HTMLButtonElement[] = [];
|
|
137
|
+
private texts = '';
|
|
138
|
+
/** The card a gamepad would pick; lit only once a pad has been used. */
|
|
139
|
+
private focus = 0;
|
|
140
|
+
/** Reads an answer when deliberately navigating with keys or a pad. */
|
|
141
|
+
hear?: (text: string) => void;
|
|
142
|
+
|
|
143
|
+
constructor() {
|
|
144
|
+
addEventListener('keydown', (e) => {
|
|
145
|
+
const n = Number(e.key);
|
|
146
|
+
if (n >= 1 && n <= this.picks.length) this.picks[n - 1]();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
show(options: Array<{ text: string; action?: string; pick: () => void }>, hero?: { name: string; portrait?: string }) {
|
|
151
|
+
this.picks = options.map((o) => o.pick);
|
|
152
|
+
this.root.hidden = options.length === 0;
|
|
153
|
+
// The hint line under the picture would run through the panel; it steps aside while answers are up.
|
|
154
|
+
document.body.classList.toggle('talking', options.length > 0);
|
|
155
|
+
// New answers start from the first card; the same ones redrawn keep the lit one.
|
|
156
|
+
const texts = options.map((o) => o.text).join('\n');
|
|
157
|
+
const fresh = texts !== this.texts;
|
|
158
|
+
if (fresh) [this.texts, this.focus] = [texts, 0];
|
|
159
|
+
// A card lit from the keys stays lit through the talk; a new talk starts unlit.
|
|
160
|
+
if (!options.length) this.root.classList.remove('lit');
|
|
161
|
+
this.cards = [];
|
|
162
|
+
if (!options.length) return this.root.replaceChildren();
|
|
163
|
+
const who = el('div', 'who');
|
|
164
|
+
if (hero?.portrait) {
|
|
165
|
+
const face = el('div', 'face');
|
|
166
|
+
const img = el('img');
|
|
167
|
+
img.src = hero.portrait;
|
|
168
|
+
img.alt = '';
|
|
169
|
+
face.append(img);
|
|
170
|
+
who.append(face);
|
|
171
|
+
}
|
|
172
|
+
if (hero) who.append(el('span', 'tag', hero.name));
|
|
173
|
+
this.cards = options.map((o, i) => {
|
|
174
|
+
const b = el('button', 'answer');
|
|
175
|
+
if (o.action) b.dataset.action = o.action;
|
|
176
|
+
b.append(el('span', 'key', String(i + 1)), document.createTextNode(o.text));
|
|
177
|
+
b.onclick = o.pick;
|
|
178
|
+
return b;
|
|
179
|
+
});
|
|
180
|
+
// Walking away ends the talk as Goodbye does; said once under answers that are not just a goodbye.
|
|
181
|
+
const leave = options.some((o) => o.action !== 'leave') ? [el('div', 'leave')] : [];
|
|
182
|
+
for (const l of leave) l.append(el('span', 'keys', words.leave), el('span', 'pads', words.padLeave), el('span', 'taps', words.tapLeave));
|
|
183
|
+
this.root.replaceChildren(who, ...this.cards, ...leave);
|
|
184
|
+
this.light();
|
|
185
|
+
// New answers rise in and their cards pulse twice, so a choice waiting for the player is not missed.
|
|
186
|
+
if (fresh) {
|
|
187
|
+
this.root.animate([{ opacity: 0, transform: 'translate(-50%, 24px) scale(.96)' }, { opacity: 1, transform: 'translate(-50%, 0) scale(1)' }], { duration: 320, easing: 'cubic-bezier(.2,.9,.3,1.2)' });
|
|
188
|
+
this.cards.forEach((c, i) => c.animate(
|
|
189
|
+
[{ boxShadow: '0 0 0 0 #ffd37acc, 0 4px 0 #2b211840' }, { boxShadow: '0 0 0 14px #ffd37a00, 0 4px 0 #2b211840' }],
|
|
190
|
+
{ duration: 900, delay: 250 + i * 90, iterations: 2, easing: 'ease-out' },
|
|
191
|
+
));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Moves the lit card up (-1) or down (1), round from the last to the first; `keys` lights it without a pad. */
|
|
196
|
+
move(by: number, keys = false) {
|
|
197
|
+
if (!this.picks.length) return;
|
|
198
|
+
if (keys && !this.root.classList.contains('lit')) {
|
|
199
|
+
// The first press lights the first card, so the player sees where they are before moving on.
|
|
200
|
+
this.root.classList.add('lit');
|
|
201
|
+
this.focus = by > 0 ? 0 : this.picks.length - 1;
|
|
202
|
+
this.light();
|
|
203
|
+
return void this.hear?.(this.texts.split('\n')[this.focus]);
|
|
204
|
+
}
|
|
205
|
+
this.focus = (this.focus + by + this.picks.length) % this.picks.length;
|
|
206
|
+
this.light();
|
|
207
|
+
this.hear?.(this.texts.split('\n')[this.focus]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Picks the lit card; false when no answers are up. */
|
|
211
|
+
pick(): boolean {
|
|
212
|
+
// Enter before any card was lit lights the first one rather than answering unseen.
|
|
213
|
+
if (!document.body.classList.contains('pad') && !this.root.classList.contains('lit')) return this.move(1, true), !!this.cards.length;
|
|
214
|
+
const card = this.cards[this.focus];
|
|
215
|
+
if (!card) return false;
|
|
216
|
+
card.click();
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private light() {
|
|
221
|
+
this.cards.forEach((c, i) => c.classList.toggle('focus', i === this.focus));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The ending as a paper card over the dimmed scene: its title, its text, what the hero carried, and a way to play again. */
|
|
226
|
+
export function showEnding(ending: { title: string; text: string } | undefined, carried: ItemLook[]) {
|
|
227
|
+
const root = document.getElementById('ending')!;
|
|
228
|
+
root.hidden = !ending;
|
|
229
|
+
if (!ending) return root.replaceChildren();
|
|
230
|
+
const card = el('div', 'card');
|
|
231
|
+
const icons = el('div', 'carried');
|
|
232
|
+
for (const item of carried.filter((i) => i.icon)) {
|
|
233
|
+
const img = el('img');
|
|
234
|
+
img.src = item.icon!;
|
|
235
|
+
img.alt = item.name;
|
|
236
|
+
img.title = item.name;
|
|
237
|
+
icons.append(img);
|
|
238
|
+
}
|
|
239
|
+
const again = el('button', 'again', words.again);
|
|
240
|
+
again.onclick = () => {
|
|
241
|
+
// From the start: drop a place= the page may have been opened with.
|
|
242
|
+
const url = new URL(location.href);
|
|
243
|
+
url.searchParams.delete('place');
|
|
244
|
+
location.href = url.toString();
|
|
245
|
+
};
|
|
246
|
+
card.append(el('div', 'over', words.end), el('h1', '', ending.title), el('p', '', ending.text), ...(icons.childElementCount ? [icons] : []), again);
|
|
247
|
+
root.replaceChildren(card);
|
|
248
|
+
card.animate([{ opacity: 0, transform: 'translateY(16px) scale(.97)' }, { opacity: 1, transform: 'none' }], { duration: 700, easing: 'ease-out' });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Lays the HTML layer over the canvas as drawn (letterboxed, scaled): 1 CSS px of it is 1 px of the frame. */
|
|
252
|
+
export function fitHud(canvas: HTMLCanvasElement) {
|
|
253
|
+
const hud = document.getElementById('hud')!;
|
|
254
|
+
const r = canvas.getBoundingClientRect();
|
|
255
|
+
const k = r.width / (Number(canvas.dataset.w) || canvas.width);
|
|
256
|
+
// A canvas bigger than the page (?fill=1) has the HTML on the part of it the page shows, the bag on the page.
|
|
257
|
+
const [left, top] = [Math.max(0, r.left), Math.max(0, r.top)];
|
|
258
|
+
const t = `translate(${left}px, ${top}px) scale(${k})`;
|
|
259
|
+
if (hud.style.transform !== t) hud.style.transform = t;
|
|
260
|
+
const [w, h] = [`${(Math.min(innerWidth, r.right) - left) / k}px`, `${(Math.min(innerHeight, r.bottom) - top) / k}px`];
|
|
261
|
+
if (hud.style.width !== w) hud.style.width = w;
|
|
262
|
+
if (hud.style.height !== h) hud.style.height = h;
|
|
263
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// The weather of a place (weather.json): rain or snow over the whole frame, each drop ending on the floor row
|
|
2
|
+
// as far away as it is, some of them ringing on water and puddles or splashing on the floor, and drops that
|
|
3
|
+
// gather on the edges Spark named (eaves, a gutter) and fall to the ground under them. Seeded, so a capture
|
|
4
|
+
// run rains the same every time.
|
|
5
|
+
import { personPx, type Geometry } from '../src/play/geometry.ts';
|
|
6
|
+
import { hashSeed, seeded, type Weather } from '../src/play/weather.ts';
|
|
7
|
+
import type { Pool } from './glows.ts';
|
|
8
|
+
|
|
9
|
+
type Shift = (y: number) => [number, number];
|
|
10
|
+
type Mark = { x: number; y: number; t: number; size: number; ring: boolean };
|
|
11
|
+
type Bank = { x: number; y: number; w: number; h: number; v: number; a: number };
|
|
12
|
+
type Drip = { x: number; y: number; to: number; t: number; wait: number; falling: boolean };
|
|
13
|
+
|
|
14
|
+
/** One landing in this many leaves a mark (a ring or a splash); all of them would boil the floor. */
|
|
15
|
+
const MARK_EVERY = 9;
|
|
16
|
+
const RING_LIFE = 0.9;
|
|
17
|
+
const SPLASH_LIFE = 0.22;
|
|
18
|
+
/** A drop gathers on its edge this long before it lets go. */
|
|
19
|
+
const GATHER = 0.4;
|
|
20
|
+
const GRAVITY_M = 9.81;
|
|
21
|
+
|
|
22
|
+
export class WeatherFx {
|
|
23
|
+
private readonly rand: () => number;
|
|
24
|
+
private readonly W: number;
|
|
25
|
+
private readonly H: number;
|
|
26
|
+
/** Falling rain or snow: x, y, depth (0 far .. 1 near), and the row each one ends on. */
|
|
27
|
+
private readonly px: Float32Array;
|
|
28
|
+
private readonly py: Float32Array;
|
|
29
|
+
private readonly pz: Float32Array;
|
|
30
|
+
private readonly end: Float32Array;
|
|
31
|
+
private readonly marks: Mark[] = [];
|
|
32
|
+
private readonly drips: Drip[] = [];
|
|
33
|
+
/** Some pixels of the ripple mask, to ring at between landings. */
|
|
34
|
+
private readonly ringAt: Uint32Array;
|
|
35
|
+
private ringDebt = 0;
|
|
36
|
+
/** Mist: wide soft banks drifting across the frame with the wind, one blurred blob drawn stretched. */
|
|
37
|
+
private readonly banks: Bank[] = [];
|
|
38
|
+
private blob?: HTMLCanvasElement;
|
|
39
|
+
private time = 0;
|
|
40
|
+
|
|
41
|
+
constructor(readonly w: Weather, private readonly g: Geometry, private readonly ripples?: Uint8Array, key = '') {
|
|
42
|
+
this.rand = seeded(hashSeed(key || 'weather'));
|
|
43
|
+
this.W = g.frame.width;
|
|
44
|
+
this.H = g.frame.height;
|
|
45
|
+
const n = w.fall === 'rain' ? Math.round(140 + 560 * w.strength) : w.fall === 'snow' ? Math.round(90 + 320 * w.strength) : 0;
|
|
46
|
+
this.px = new Float32Array(n);
|
|
47
|
+
this.py = new Float32Array(n);
|
|
48
|
+
this.pz = new Float32Array(n);
|
|
49
|
+
this.end = new Float32Array(n);
|
|
50
|
+
for (let i = 0; i < n; i++) this.spawn(i, this.rand() * this.H);
|
|
51
|
+
const at: number[] = [];
|
|
52
|
+
if (ripples) for (let i = 0; i < ripples.length; i += 7) if (ripples[i]) at.push(i);
|
|
53
|
+
this.ringAt = Uint32Array.from(at);
|
|
54
|
+
const fog = w.fog ?? 0;
|
|
55
|
+
if (fog > 0) {
|
|
56
|
+
const hor = Math.max(0, Math.min(this.H * 0.7, g.scale.horizonY));
|
|
57
|
+
const dir = w.wind < 0 ? -1 : 1;
|
|
58
|
+
for (let k = 0, n = 5 + Math.round(7 * fog); k < n; k++) {
|
|
59
|
+
const bw = this.W * (0.35 + 0.4 * this.rand());
|
|
60
|
+
const near = this.rand();
|
|
61
|
+
// From a little above the horizon down to the near floor; near banks are bigger, faster and thinner.
|
|
62
|
+
this.banks.push({ x: this.rand() * (this.W + bw) - bw, y: hor - this.H * 0.12 + near * (this.H - hor) * 0.95, w: bw * (0.8 + 0.5 * near), h: bw * (0.16 + 0.08 * this.rand()), v: dir * (10 + 16 * near + 30 * Math.abs(w.wind)), a: fog * (0.35 + 0.25 * this.rand()) * (1 - 0.35 * near) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const d of w.drips) d.from.forEach(([x, y], k) => this.drips.push({ x, y, to: d.to[k], t: 0, wait: this.rand() * 2.5, falling: false }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private spawn(i: number, y: number) {
|
|
69
|
+
const z = this.rand() ** 0.7;
|
|
70
|
+
this.pz[i] = z;
|
|
71
|
+
// Far drops end high on the floor, near ones at the bottom of the frame.
|
|
72
|
+
const hor = Math.max(0, this.g.scale.horizonY);
|
|
73
|
+
this.end[i] = hor + (this.H - hor) * (0.15 + 0.85 * z) + this.rand() * 30;
|
|
74
|
+
this.py[i] = y - this.H * 0.15;
|
|
75
|
+
// Starting further upwind, so a slanted fall still covers the frame.
|
|
76
|
+
this.px[i] = this.rand() * (this.W + Math.abs(this.w.wind) * this.H) - Math.max(0, this.w.wind) * this.H;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private walkable(x: number, y: number): boolean {
|
|
80
|
+
const row = this.g.walk[Math.floor(y / this.g.cell)];
|
|
81
|
+
return !!row && row[Math.floor(x / this.g.cell)] === '#';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private wetAt(x: number, y: number): boolean {
|
|
85
|
+
if (!this.ripples || x < 0 || y < 0 || x >= this.W || y >= this.H) return false;
|
|
86
|
+
return !!this.ripples[Math.floor(y) * this.W + Math.floor(x)];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A ring on water or a puddle, a splash on the floor, nothing elsewhere (a roof, the sky). */
|
|
90
|
+
private land(x: number, y: number) {
|
|
91
|
+
// Water past the horizon line still rings, small (the scale's horizon is only where people shrink to nothing).
|
|
92
|
+
const p = Math.max(24, personPx(this.g, y));
|
|
93
|
+
if (this.wetAt(x, y)) this.marks.push({ x, y, t: 0, size: p * 0.09, ring: true });
|
|
94
|
+
else if (y > this.g.scale.horizonY && this.walkable(x, y)) this.marks.push({ x, y, t: 0, size: p * 0.05, ring: false });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
update(dt: number) {
|
|
98
|
+
this.time += dt;
|
|
99
|
+
for (const b of this.banks) {
|
|
100
|
+
b.x += b.v * dt;
|
|
101
|
+
if (b.v > 0 && b.x > this.W) b.x = -b.w;
|
|
102
|
+
if (b.v < 0 && b.x + b.w < 0) b.x = this.W;
|
|
103
|
+
}
|
|
104
|
+
const { w } = this;
|
|
105
|
+
const rain = w.fall === 'rain';
|
|
106
|
+
for (let i = 0; i < this.px.length; i++) {
|
|
107
|
+
const z = this.pz[i];
|
|
108
|
+
if (rain) {
|
|
109
|
+
const v = (650 + 1150 * z) * dt;
|
|
110
|
+
this.py[i] += v;
|
|
111
|
+
this.px[i] += w.wind * v;
|
|
112
|
+
} else {
|
|
113
|
+
const v = (28 + 80 * z) * dt;
|
|
114
|
+
this.py[i] += v;
|
|
115
|
+
this.px[i] += w.wind * v + Math.sin(this.time * (0.8 + z) + i) * 14 * z * dt;
|
|
116
|
+
}
|
|
117
|
+
if (this.py[i] < this.end[i]) continue;
|
|
118
|
+
if (rain && i % MARK_EVERY === 0) this.land(this.px[i], this.end[i]);
|
|
119
|
+
this.spawn(i, -this.rand() * 40);
|
|
120
|
+
}
|
|
121
|
+
// Between landings, puddles and water still ring now and then, so a small puddle is never still for long.
|
|
122
|
+
if (rain && this.ringAt.length) {
|
|
123
|
+
this.ringDebt += dt * (6 + 30 * w.strength) * Math.min(1, this.ringAt.length / 4000);
|
|
124
|
+
while (this.ringDebt >= 1) {
|
|
125
|
+
this.ringDebt--;
|
|
126
|
+
const i = this.ringAt[Math.floor(this.rand() * this.ringAt.length)];
|
|
127
|
+
this.land(i % this.W, Math.floor(i / this.W));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const m of this.marks) m.t += dt;
|
|
131
|
+
for (let k = this.marks.length - 1; k >= 0; k--) if (this.marks[k].t > (this.marks[k].ring ? RING_LIFE : SPLASH_LIFE)) this.marks.splice(k, 1);
|
|
132
|
+
// Heavier rain, faster drips; a dry place drips slowly (a cave, a pipe).
|
|
133
|
+
const pace = rain ? 0.6 + w.strength : 0.5;
|
|
134
|
+
for (const d of this.drips) {
|
|
135
|
+
d.t += dt;
|
|
136
|
+
if (!d.falling) {
|
|
137
|
+
if (d.t >= d.wait + GATHER) [d.falling, d.t] = [true, 0];
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (this.fallen(d) < d.to) continue;
|
|
141
|
+
this.land(d.x, d.to);
|
|
142
|
+
[d.falling, d.t, d.wait] = [false, 0, (0.5 + this.rand() * 2.2) / pace];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Where a falling drip is now: under gravity, at the scale of the place where it lands. */
|
|
147
|
+
private fallen(d: Drip): number {
|
|
148
|
+
const g = (GRAVITY_M * personPx(this.g, d.to)) / 1.62;
|
|
149
|
+
return d.y + 0.5 * g * d.t * d.t;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Rings and splashes on the ground, each sliding with the floor row it lies on. */
|
|
153
|
+
drawGround(ctx: CanvasRenderingContext2D, shift: Shift) {
|
|
154
|
+
if (!this.marks.length) return;
|
|
155
|
+
ctx.save();
|
|
156
|
+
ctx.lineWidth = 1;
|
|
157
|
+
for (const m of this.marks) {
|
|
158
|
+
const [sx, sy] = shift(m.y);
|
|
159
|
+
const x = m.x + sx;
|
|
160
|
+
const y = m.y + sy;
|
|
161
|
+
if (m.ring) {
|
|
162
|
+
const p = m.t / RING_LIFE;
|
|
163
|
+
const r = Math.max(1, m.size * (0.25 + p));
|
|
164
|
+
ctx.strokeStyle = `rgba(215,228,240,${0.55 * (1 - p)})`;
|
|
165
|
+
ctx.beginPath();
|
|
166
|
+
ctx.ellipse(x, y, r, r * 0.38, 0, 0, Math.PI * 2);
|
|
167
|
+
ctx.stroke();
|
|
168
|
+
if (r > 4) {
|
|
169
|
+
ctx.strokeStyle = `rgba(215,228,240,${0.3 * (1 - p)})`;
|
|
170
|
+
ctx.beginPath();
|
|
171
|
+
ctx.ellipse(x, y, r * 0.55, r * 0.21, 0, 0, Math.PI * 2);
|
|
172
|
+
ctx.stroke();
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
// A small crown: a few droplets thrown up and out, falling back.
|
|
176
|
+
const p = m.t / SPLASH_LIFE;
|
|
177
|
+
const s = Math.max(2, m.size);
|
|
178
|
+
ctx.fillStyle = `rgba(220,232,245,${0.7 * (1 - p)})`;
|
|
179
|
+
for (let k = -2; k <= 2; k++) {
|
|
180
|
+
const dx = k * s * 0.35 * (0.4 + p);
|
|
181
|
+
const dy = -s * (0.9 - Math.abs(k) * 0.2) * Math.sin(Math.PI * p);
|
|
182
|
+
const r = Math.max(0.6, s * 0.09);
|
|
183
|
+
ctx.fillRect(x + dx - r / 2, y + dy - r / 2, r, r);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
ctx.restore();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Drops gathering on their edges and falling, each sliding with the floor row it falls to. */
|
|
191
|
+
drawDrips(ctx: CanvasRenderingContext2D, shift: Shift) {
|
|
192
|
+
if (!this.drips.length) return;
|
|
193
|
+
ctx.save();
|
|
194
|
+
for (const d of this.drips) {
|
|
195
|
+
const [sx, sy] = shift(d.to);
|
|
196
|
+
const s = Math.max(1.2, personPx(this.g, d.to) * 0.012);
|
|
197
|
+
if (!d.falling) {
|
|
198
|
+
if (d.t < d.wait) continue;
|
|
199
|
+
// Swelling on the edge before it lets go.
|
|
200
|
+
const grow = (d.t - d.wait) / GATHER;
|
|
201
|
+
ctx.fillStyle = `rgba(225,235,245,${0.35 + 0.45 * grow})`;
|
|
202
|
+
ctx.beginPath();
|
|
203
|
+
ctx.ellipse(d.x + sx, d.y + sy + s * grow * 0.6, s * (0.5 + 0.5 * grow), s * (0.6 + 0.8 * grow), 0, 0, Math.PI * 2);
|
|
204
|
+
ctx.fill();
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const y = Math.min(d.to, this.fallen(d));
|
|
208
|
+
const v = (GRAVITY_M * personPx(this.g, d.to)) / 1.62 * d.t;
|
|
209
|
+
const len = Math.min(s * 8, Math.max(s * 1.5, v * 0.025));
|
|
210
|
+
ctx.strokeStyle = 'rgba(225,235,245,0.75)';
|
|
211
|
+
ctx.lineWidth = s;
|
|
212
|
+
ctx.lineCap = 'round';
|
|
213
|
+
ctx.beginPath();
|
|
214
|
+
ctx.moveTo(d.x + sx, y + sy - len);
|
|
215
|
+
ctx.lineTo(d.x + sx, y + sy);
|
|
216
|
+
ctx.stroke();
|
|
217
|
+
}
|
|
218
|
+
ctx.restore();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The rain or snow itself, over everything: three depths, far ones thin, faint and short. Drops falling through
|
|
222
|
+
* a light's pool (a lamp, a lighthouse beam: GlowFx.pools) catch it and shine in its colour. */
|
|
223
|
+
drawAir(ctx: CanvasRenderingContext2D, lights: Pool[] = []) {
|
|
224
|
+
this.drawFog(ctx);
|
|
225
|
+
const n = this.px.length;
|
|
226
|
+
if (!n) return;
|
|
227
|
+
ctx.save();
|
|
228
|
+
const { wind, strength } = this.w;
|
|
229
|
+
if (this.w.fall === 'rain') {
|
|
230
|
+
ctx.lineCap = 'round';
|
|
231
|
+
const norm = 1 / Math.hypot(wind, 1);
|
|
232
|
+
for (const [lo, hi] of [[0, 0.4], [0.4, 0.75], [0.75, 1.01]]) {
|
|
233
|
+
const z = (lo + hi) / 2;
|
|
234
|
+
ctx.strokeStyle = `rgba(200,215,235,${(0.16 + 0.3 * z) * (0.65 + 0.35 * strength)})`;
|
|
235
|
+
ctx.lineWidth = 0.7 + 1.1 * z;
|
|
236
|
+
const len = (10 + 38 * z) * (this.H / 900);
|
|
237
|
+
ctx.beginPath();
|
|
238
|
+
for (let i = 0; i < n; i++) {
|
|
239
|
+
if (this.pz[i] < lo || this.pz[i] >= hi) continue;
|
|
240
|
+
const x = this.px[i];
|
|
241
|
+
const y = this.py[i];
|
|
242
|
+
ctx.moveTo(x - wind * len * norm, y - len * norm);
|
|
243
|
+
ctx.lineTo(x, y);
|
|
244
|
+
}
|
|
245
|
+
ctx.stroke();
|
|
246
|
+
}
|
|
247
|
+
if (lights.length) this.drawLitRain(ctx, lights, norm);
|
|
248
|
+
} else {
|
|
249
|
+
ctx.fillStyle = 'rgba(245,248,252,0.85)';
|
|
250
|
+
for (let i = 0; i < n; i++) {
|
|
251
|
+
const z = this.pz[i];
|
|
252
|
+
const r = 0.8 + 2.4 * z;
|
|
253
|
+
ctx.globalAlpha = 0.45 + 0.5 * z;
|
|
254
|
+
ctx.beginPath();
|
|
255
|
+
ctx.arc(this.px[i], this.py[i], r, 0, Math.PI * 2);
|
|
256
|
+
ctx.fill();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
ctx.restore();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private drawLitRain(ctx: CanvasRenderingContext2D, lights: Pool[], norm: number) {
|
|
263
|
+
const { wind } = this.w;
|
|
264
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
265
|
+
for (let i = 0; i < this.px.length; i++) {
|
|
266
|
+
const [x, y, z] = [this.px[i], this.py[i], this.pz[i]];
|
|
267
|
+
let [best, c] = [0, ''];
|
|
268
|
+
for (const l of lights) {
|
|
269
|
+
const d = Math.hypot(x - l.x, y - l.y);
|
|
270
|
+
if (d >= l.r) continue;
|
|
271
|
+
const a = l.a * (1 - d / l.r) ** 1.5;
|
|
272
|
+
if (a > best) [best, c] = [a, l.c];
|
|
273
|
+
}
|
|
274
|
+
if (best < 0.04) continue;
|
|
275
|
+
const len = (10 + 38 * z) * (this.H / 900);
|
|
276
|
+
ctx.strokeStyle = `rgba(${c},${Math.min(1, best * (0.6 + 0.6 * z))})`;
|
|
277
|
+
ctx.lineWidth = 1 + 1.4 * z;
|
|
278
|
+
ctx.beginPath();
|
|
279
|
+
ctx.moveTo(x - wind * len * norm, y - len * norm);
|
|
280
|
+
ctx.lineTo(x, y);
|
|
281
|
+
ctx.stroke();
|
|
282
|
+
}
|
|
283
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Mist over the place: each bank a soft white blob, breathing a little as it drifts. */
|
|
287
|
+
private drawFog(ctx: CanvasRenderingContext2D) {
|
|
288
|
+
if (!this.banks.length) return;
|
|
289
|
+
if (!this.blob) {
|
|
290
|
+
this.blob = Object.assign(document.createElement('canvas'), { width: 128, height: 128 });
|
|
291
|
+
const c = this.blob.getContext('2d')!;
|
|
292
|
+
const grad = c.createRadialGradient(64, 64, 0, 64, 64, 64);
|
|
293
|
+
grad.addColorStop(0, 'rgba(228,232,238,1)');
|
|
294
|
+
grad.addColorStop(0.55, 'rgba(228,232,238,0.45)');
|
|
295
|
+
grad.addColorStop(1, 'rgba(228,232,238,0)');
|
|
296
|
+
c.fillStyle = grad;
|
|
297
|
+
c.fillRect(0, 0, 128, 128);
|
|
298
|
+
}
|
|
299
|
+
ctx.save();
|
|
300
|
+
for (const [k, b] of this.banks.entries()) {
|
|
301
|
+
ctx.globalAlpha = b.a * (0.8 + 0.2 * Math.sin(this.time * 0.3 + k * 1.7));
|
|
302
|
+
ctx.drawImage(this.blob, b.x, b.y - b.h / 2, b.w, b.h);
|
|
303
|
+
}
|
|
304
|
+
ctx.restore();
|
|
305
|
+
}
|
|
306
|
+
}
|
package/app/web/words.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The few words the game says itself, in the language the world is written in (world.json language).
|
|
2
|
+
import { goodbye } from '../src/story/phrases.ts';
|
|
3
|
+
const WORDS = {
|
|
4
|
+
English: { useOn: (a: string, b: string) => `Use ${a} on ${b}`, nope: (a: string) => `The ${a} won't help here.`, goodbye: goodbye('English'), end: 'The end', again: 'Play again', player: 'Player', padHint: 'gamepad: stick or d-pad walks · A uses · B puts back or looks · X Y or shoulders pick an item · in a talk up/down and A answer, B says goodbye', leave: '↑↓ and Enter answer · Esc or a click away says goodbye', padLeave: 'B says goodbye', tapLeave: 'Tap away to say goodbye', sound: 'Click once for sound', hintWith: (a: string) => `Maybe my ${a[0].toLowerCase()}${a.slice(1)} can help over there.`, hint: 'Maybe something over there can help.' },
|
|
5
|
+
Polish: { useOn: (a: string, b: string) => `Użyj: ${a} → ${b}`, nope: (a: string) => `${a[0].toUpperCase()}${a.slice(1)} tu nie pomoże.`, goodbye: goodbye('Polish'), end: 'Koniec', again: 'Zagraj jeszcze raz', player: 'Gracz', padHint: 'pad: gałka lub krzyżak chodzi · A używa · B odkłada lub ogląda · X Y lub boczne wybierają przedmiot · w rozmowie góra/dół i A, B żegna', leave: '↑↓ i Enter odpowiadają · Esc albo klik obok żegna', padLeave: 'B żegna', tapLeave: 'Stuknij obok, żeby się pożegnać', sound: 'Kliknij raz, żeby włączyć dźwięk', hintWith: (a: string) => `Może ${a.toLowerCase()} przyda się tam?`, hint: 'Może tam coś pomoże?' },
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export let words = WORDS.English;
|
|
9
|
+
|
|
10
|
+
export function useLanguage(language?: string) {
|
|
11
|
+
words = WORDS[language as keyof typeof WORDS] ?? WORDS.English;
|
|
12
|
+
}
|