meshpad 0.0.0 → 0.1.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/src/hud.js ADDED
@@ -0,0 +1,198 @@
1
+ let hudId = 0;
2
+
3
+ const icon = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true"><g class="meshpad-icon-outline">${paths}</g>${paths}</svg>`;
4
+ const heart = icon('<path d="M12 21 3.4 12.6a5.5 5.5 0 0 1 7.8-7.8l.8.8.8-.8a5.5 5.5 0 0 1 7.8 7.8Z"/>');
5
+ const difficultyLabels = { normal: 'Normal', hard: 'Hard', 'very-hard': 'Very hard', 'ultra-hard': 'Ultra hard' };
6
+
7
+ export function createHUD(root, levels, actions) {
8
+ const titleId = `meshpad-result-title-${++hudId}`;
9
+ const layer = document.createElement('div');
10
+ layer.className = 'meshpad-hud';
11
+ layer.innerHTML = `
12
+ <header class="meshpad-top">
13
+ <nav aria-label="Level navigation">
14
+ <h1 aria-live="polite" aria-atomic="true"><button type="button" class="meshpad-level-chooser" data-action="levels" aria-haspopup="dialog" aria-expanded="false"><span class="meshpad-level-label"></span>${icon('<path d="m6 9 6 6 6-6"/>')}</button></h1>
15
+ <div class="meshpad-resource" hidden></div>
16
+ <div class="meshpad-hud-actions">
17
+ <button type="button" data-action="restart" aria-label="Restart level">
18
+ ${icon('<path d="M4 10a8 8 0 1 1 1.8 7M4 4v6h6"/>')}
19
+ </button>
20
+ </div>
21
+ </nav>
22
+ </header>
23
+ <section class="meshpad-overlay" hidden>
24
+ <div class="meshpad-panel" role="dialog" aria-modal="true" aria-labelledby="${titleId}" tabindex="-1">
25
+ <div class="meshpad-panel-heading">
26
+ <h2 id="${titleId}"></h2>
27
+ <button type="button" class="meshpad-close" aria-label="Close levels list" hidden>${icon('<path d="m6 6 12 12M18 6 6 18"/>')}</button>
28
+ </div>
29
+ <p class="meshpad-result-detail" hidden></p>
30
+ <div class="meshpad-levels" hidden></div>
31
+ <button type="button" class="meshpad-primary"><span class="meshpad-button-label"></span></button>
32
+ <button type="button" class="meshpad-secondary" hidden><span class="meshpad-button-label">Retry</span></button>
33
+ </div>
34
+ </section>`;
35
+ root.append(layer);
36
+ const find = (selector) => layer.querySelector(selector);
37
+ const top = find('.meshpad-top');
38
+ const levelLabel = find('.meshpad-level-label');
39
+ const levelsButton = find('[data-action="levels"]');
40
+ const restart = find('[data-action="restart"]');
41
+ const resource = find('.meshpad-resource');
42
+ const overlay = find('.meshpad-overlay');
43
+ const panel = find('.meshpad-panel');
44
+ const title = find('h2');
45
+ const closeButton = find('.meshpad-close');
46
+ const detail = find('.meshpad-result-detail');
47
+ const levelList = find('.meshpad-levels');
48
+ levelList.id = `meshpad-levels-${hudId}`;
49
+ levelsButton.setAttribute('aria-controls', levelList.id);
50
+ const primary = find('.meshpad-primary');
51
+ const secondary = find('.meshpad-secondary');
52
+ const primaryLabel = find('.meshpad-button-label');
53
+ const listeners = new AbortController();
54
+ const listen = (element, event, callback) => element.addEventListener(event, callback, { signal: listeners.signal });
55
+ let index = 0;
56
+ let returnFocus;
57
+ let showingLevels = false;
58
+ let resultAction = actions.restart;
59
+ let resourceKey;
60
+ const hideResult = () => {
61
+ const restoreFocus = panel.contains(document.activeElement);
62
+ overlay.hidden = true;
63
+ levelsButton.setAttribute('aria-expanded', 'false');
64
+ top.inert = false;
65
+ if (restoreFocus) {
66
+ const target = top.contains(returnFocus) && !returnFocus.disabled ? returnFocus : restart;
67
+ target.focus({ preventScroll: true });
68
+ }
69
+ };
70
+ const show = (focusTarget = primary) => {
71
+ if (overlay.hidden) returnFocus = document.activeElement;
72
+ top.inert = true;
73
+ overlay.hidden = false;
74
+ focusTarget.focus({ preventScroll: true });
75
+ };
76
+ const closeLevels = () => {
77
+ showingLevels = false;
78
+ hideResult();
79
+ actions.closeLevels();
80
+ };
81
+ listen(restart, 'click', actions.restart);
82
+ listen(primary, 'click', () => resultAction());
83
+ listen(secondary, 'click', actions.restart);
84
+ listen(closeButton, 'click', closeLevels);
85
+ listen(levelsButton, 'click', () => {
86
+ actions.openLevels();
87
+ showingLevels = true;
88
+ panel.dataset.view = 'levels';
89
+ overlay.dataset.view = 'levels';
90
+ levelsButton.setAttribute('aria-expanded', 'true');
91
+ title.textContent = 'Levels';
92
+ detail.hidden = true;
93
+ levelList.hidden = false;
94
+ secondary.hidden = true;
95
+ primary.hidden = true;
96
+ closeButton.hidden = false;
97
+ show(closeButton);
98
+ const selected = levelList.querySelector('[aria-current]');
99
+ selected?.focus({ preventScroll: true });
100
+ selected?.scrollIntoView({ block: 'nearest' });
101
+ });
102
+ levels.forEach((level, levelIndex) => {
103
+ const button = document.createElement('button');
104
+ button.type = 'button';
105
+ button.setAttribute('aria-label', `Level ${levelIndex + 1}${level.name ? `: ${level.name}` : ''}`);
106
+ const info = document.createElement('span');
107
+ info.className = 'meshpad-level-info';
108
+ const number = document.createElement('span');
109
+ number.className = 'meshpad-level-number';
110
+ number.textContent = `Level ${levelIndex + 1}`;
111
+ info.append(number);
112
+ if (level.name) {
113
+ const name = document.createElement('span');
114
+ name.className = 'meshpad-level-name';
115
+ name.textContent = level.name;
116
+ name.title = level.name;
117
+ info.append(name);
118
+ }
119
+ const difficulty = document.createElement('span');
120
+ difficulty.id = `meshpad-difficulty-${hudId}-${levelIndex}`;
121
+ difficulty.className = 'meshpad-difficulty';
122
+ difficulty.dataset.difficulty = level.difficulty ?? 'normal';
123
+ difficulty.textContent = difficultyLabels[difficulty.dataset.difficulty];
124
+ button.setAttribute('aria-describedby', difficulty.id);
125
+ button.append(info, difficulty);
126
+ listen(button, 'click', () => actions.loadLevel(levelIndex));
127
+ levelList.append(button);
128
+ });
129
+ listen(panel, 'keydown', (event) => {
130
+ if (event.key === 'Escape' && showingLevels) { event.preventDefault(); closeLevels(); }
131
+ if (event.key === 'Tab') {
132
+ const buttons = [...panel.querySelectorAll('button')].filter((button) => button.offsetParent !== null && !button.disabled);
133
+ const first = buttons[0];
134
+ const last = buttons.at(-1);
135
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
136
+ event.preventDefault(); last.focus();
137
+ } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === panel)) {
138
+ event.preventDefault(); first.focus();
139
+ }
140
+ }
141
+ });
142
+ const updateResource = (failure) => {
143
+ const { type, remaining, initial } = failure;
144
+ resource.hidden = type === 'default';
145
+ top.dataset.mode = type;
146
+ if (type === 'default') { resourceKey = undefined; return; }
147
+ const seconds = Math.ceil(Math.max(0, remaining - 1e-9));
148
+ const display = type === 'time' ? `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}` : String(remaining);
149
+ const key = `${type}:${initial}:${display}`;
150
+ if (resourceKey === key) return;
151
+ resourceKey = key;
152
+ resource.className = `meshpad-resource meshpad-resource-${type}`;
153
+ resource.dataset.low = String(type === 'hearts' ? remaining <= 1 : remaining <= (type === 'time' ? 10 : 3));
154
+ resource.setAttribute('role', type === 'time' ? 'timer' : 'img');
155
+ resource.setAttribute('aria-label', type === 'hearts' ? `${remaining} of ${initial} hearts` : type === 'time' ? `${display} remaining` : `${remaining} moves remaining`);
156
+ if (type === 'hearts') {
157
+ resource.innerHTML = Array.from({ length: initial }, (_, i) => `<span class="meshpad-heart${i >= remaining ? ' meshpad-heart-empty' : ''}" aria-hidden="true">${heart}</span>`).join('');
158
+ } else {
159
+ resource.innerHTML = `<span class="meshpad-resource-label">${type === 'time' ? 'Time' : 'Moves'}</span><span class="meshpad-resource-value">${display}</span>`;
160
+ }
161
+ };
162
+ return {
163
+ hideResult,
164
+ resource: updateResource,
165
+ reset(nextIndex, failure) {
166
+ index = nextIndex;
167
+ showingLevels = false;
168
+ levelLabel.textContent = `Level ${index + 1}`;
169
+ levelsButton.setAttribute('aria-label', `Level ${index + 1}, choose level`);
170
+ [...levelList.children].forEach((button, i) => {
171
+ if (i === index) button.setAttribute('aria-current', 'true');
172
+ else button.removeAttribute('aria-current');
173
+ });
174
+ updateResource(failure);
175
+ hideResult();
176
+ },
177
+ result(status, failure) {
178
+ showingLevels = false;
179
+ panel.dataset.view = 'result';
180
+ overlay.dataset.view = 'result';
181
+ primary.hidden = false;
182
+ closeButton.hidden = true;
183
+ levelsButton.setAttribute('aria-expanded', 'false');
184
+ levelList.hidden = true;
185
+ const won = status === 'won';
186
+ const hasNext = won && index < levels.length - 1;
187
+ const canPlayOn = status === 'failed' && failure && failure.type !== 'default';
188
+ title.textContent = won ? 'Level complete!' : status === 'error' ? 'Something went wrong' : failure?.remaining === 0 ? ({ hearts: 'Out of hearts!', time: 'Time’s up!', moves: 'Out of moves!' }[failure.type]) : 'Try again!';
189
+ detail.hidden = !canPlayOn;
190
+ if (canPlayOn) detail.textContent = failure.type === 'hearts' ? 'Refill your hearts and keep going.' : `Keep going with +${failure.playOnAmount} ${failure.type === 'time' ? 'seconds' : 'moves'}.`;
191
+ primaryLabel.textContent = canPlayOn ? 'Play on' : hasNext ? 'Next level' : won ? 'Play again' : 'Retry';
192
+ secondary.hidden = !canPlayOn;
193
+ resultAction = canPlayOn ? actions.playOn : hasNext ? actions.next : actions.restart;
194
+ show();
195
+ },
196
+ dispose() { listeners.abort(); layer.remove(); },
197
+ };
198
+ }
package/src/styles.css ADDED
@@ -0,0 +1,208 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&display=swap');
2
+
3
+ :root {
4
+ font-family: "Baloo 2", "Arial Rounded MT Bold", ui-rounded, system-ui, sans-serif;
5
+ color: #fff4e5;
6
+ background: #19163a;
7
+ color-scheme: dark;
8
+ -webkit-text-size-adjust: 100%;
9
+ text-size-adjust: 100%;
10
+ -webkit-tap-highlight-color: transparent;
11
+ }
12
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; }
13
+ body { position: fixed; inset: 0; }
14
+ .meshpad-root, .meshpad-root * { box-sizing: border-box; }
15
+ .meshpad-root {
16
+ --meshpad-accent: #f9c94b;
17
+ --meshpad-text-outline: #362668;
18
+ --meshpad-text-shadow: #120f2c;
19
+ font-family: "Baloo 2", "Arial Rounded MT Bold", ui-rounded, system-ui, sans-serif;
20
+ color: #fff4e5;
21
+ position: relative;
22
+ width: 100%; height: 100dvh;
23
+ overflow: hidden; isolation: isolate;
24
+ touch-action: none; overscroll-behavior: none;
25
+ user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
26
+ }
27
+ .meshpad-stage {
28
+ position: absolute; top: 50%; left: 50%;
29
+ width: 100%; height: 100%; max-width: 540px;
30
+ transform: translate(-50%, -50%);
31
+ overflow: hidden; background: #221e4e;
32
+ container-type: inline-size;
33
+ }
34
+ .meshpad-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; outline: none; touch-action: none; }
35
+ .meshpad-hud { position: absolute; inset: 0; pointer-events: none; }
36
+ .meshpad-top {
37
+ position: absolute; top: 0; left: 0; right: 0;
38
+ padding: max(16px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-left), env(safe-area-inset-right)) 12px;
39
+ }
40
+ .meshpad-top nav {
41
+ display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px;
42
+ pointer-events: auto;
43
+ padding: 8px; border: 1px solid #7770b34d; border-radius: 20px;
44
+ background: #302a60f2; box-shadow: 0 3px 0 #17133266;
45
+ }
46
+ .meshpad-top h1 {
47
+ grid-column: 1; justify-self: start; margin: 0; min-width: 0; white-space: nowrap;
48
+ font-size: 18px; font-weight: 800; line-height: 1.2;
49
+ text-align: left; font-variant-numeric: tabular-nums;
50
+ }
51
+ .meshpad-hud-actions { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: 6px; }
52
+ .meshpad-root button {
53
+ display: grid; place-items: center;
54
+ min-width: 44px; min-height: 44px; padding: 10px;
55
+ border: 1px solid #ffffff1f; border-radius: 12px;
56
+ font: inherit; font-weight: 800; line-height: 1.2; color: #fff4e5;
57
+ background: #514795; box-shadow: 0 3px 0 #211c49;
58
+ appearance: none; -webkit-appearance: none;
59
+ touch-action: manipulation; cursor: pointer;
60
+ }
61
+ .meshpad-top button { flex: 0 0 44px; pointer-events: auto; }
62
+ .meshpad-top .meshpad-level-chooser {
63
+ display: flex; align-items: center; gap: 8px; padding: 6px 10px; white-space: nowrap;
64
+ font-size: inherit;
65
+ }
66
+ .meshpad-level-label { line-height: 1.2; word-spacing: 2px; }
67
+ .meshpad-level-label, .meshpad-resource-label, .meshpad-resource-value,
68
+ .meshpad-panel h2, .meshpad-result-detail, .meshpad-button-label,
69
+ .meshpad-level-number, .meshpad-level-name, .meshpad-difficulty {
70
+ -webkit-text-stroke: 2px var(--meshpad-text-outline);
71
+ paint-order: stroke fill;
72
+ text-shadow: 0 2px 0 var(--meshpad-text-shadow);
73
+ }
74
+ .meshpad-level-label, .meshpad-resource-value, .meshpad-panel h2, .meshpad-button-label, .meshpad-level-number {
75
+ -webkit-text-stroke-width: 3px;
76
+ text-shadow: 0 3px 0 var(--meshpad-text-shadow);
77
+ }
78
+ .meshpad-resource-label, .meshpad-level-name, .meshpad-difficulty {
79
+ -webkit-text-stroke-width: 1px;
80
+ text-shadow: 0 1px 0 var(--meshpad-text-shadow);
81
+ }
82
+ .meshpad-top .meshpad-level-chooser svg { width: 12px; height: 12px; flex-shrink: 0; stroke-width: 3; }
83
+ .meshpad-level-chooser[aria-expanded="true"] svg { transform: rotate(180deg); }
84
+ .meshpad-top button svg, .meshpad-close svg {
85
+ display: block; width: 22px; height: 22px;
86
+ fill: none; stroke: currentColor; stroke-width: 3.5; stroke-linecap: round; stroke-linejoin: round;
87
+ filter: drop-shadow(0 3px 0 var(--meshpad-text-shadow));
88
+ }
89
+ .meshpad-icon-outline { stroke: var(--meshpad-text-outline); stroke-width: 6.5; }
90
+ .meshpad-resource {
91
+ grid-column: 2; display: flex; align-items: center; justify-content: center;
92
+ min-width: 66px; height: 44px; padding: 3px 10px;
93
+ border: 1px solid #8c82c04d; border-radius: 11px; background: #211c49;
94
+ font-variant-numeric: tabular-nums;
95
+ }
96
+ .meshpad-resource-time, .meshpad-resource-moves { flex-direction: column; gap: 2px; }
97
+ .meshpad-resource-label { font-size: 11px; line-height: 1; font-weight: 700; color: #c8bfeb; }
98
+ .meshpad-resource-value { font-size: 20px; line-height: 1.1; font-weight: 800; }
99
+ .meshpad-resource-hearts { gap: 3px; padding: 3px 8px; }
100
+ .meshpad-heart { display: flex; width: 18px; color: #ff7d94; }
101
+ .meshpad-heart svg { width: 100%; height: 20px; fill: currentColor; stroke: #ffffff33; stroke-width: 1; filter: drop-shadow(0 1px 0 var(--meshpad-text-shadow)); }
102
+ .meshpad-heart .meshpad-icon-outline { stroke-width: 3px; }
103
+ .meshpad-heart-empty { color: #50466f; }
104
+ .meshpad-resource[data-low="true"] .meshpad-resource-value { color: #ff9bad; }
105
+ .meshpad-root button:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 #211c49; }
106
+ .meshpad-root button:disabled { opacity: .3; cursor: default; }
107
+ .meshpad-root button:focus-visible { outline: 3px solid var(--meshpad-accent); outline-offset: 3px; }
108
+ .meshpad-overlay { position: absolute; inset: 0; display: grid; place-items: center; padding: 20px; background: #19163acb; pointer-events: auto; }
109
+ .meshpad-panel {
110
+ width: 100%; max-width: 300px; max-height: 100%; overflow-y: auto; padding: 26px 20px 22px;
111
+ border: 1px solid #8c82c0; border-radius: 24px; background: #302a60;
112
+ box-shadow: 0 5px 0 #120f2c66;
113
+ text-align: center; outline: none; overscroll-behavior: contain; touch-action: pan-y;
114
+ }
115
+ .meshpad-panel h2 { margin: 0 0 20px; font-size: 28px; font-weight: 800; line-height: 1.15; }
116
+ .meshpad-result-detail { margin: -6px 0 20px; color: #d2c9ed; font-size: 16px; line-height: 1.4; }
117
+ .meshpad-panel .meshpad-primary {
118
+ --meshpad-text-outline: #956000;
119
+ --meshpad-text-shadow: #6d4100;
120
+ width: 100%; font-size: 20px; color: #fff4db; background: var(--meshpad-accent);
121
+ border-color: #ffe49b; box-shadow: 0 3px 0 #aa792a;
122
+ }
123
+ .meshpad-panel .meshpad-primary:active { box-shadow: 0 1px 0 #aa792a; }
124
+ .meshpad-panel .meshpad-secondary { width: 100%; margin-top: 12px; font-size: 17px; }
125
+ .meshpad-overlay[data-view="levels"] { padding: 0; background: #302a60; }
126
+ .meshpad-panel[data-view="levels"] {
127
+ display: flex; flex-direction: column; gap: 16px;
128
+ width: 100%; height: 100%; max-width: none; overflow: hidden;
129
+ padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-left), env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom));
130
+ border: 0; border-radius: 0; box-shadow: none;
131
+ }
132
+ .meshpad-panel[data-view="levels"] .meshpad-panel-heading {
133
+ flex: 0 0 auto; display: grid; grid-template-columns: 44px minmax(0, 1fr) 44px; align-items: center; gap: 12px;
134
+ }
135
+ .meshpad-panel[data-view="levels"] h2 { grid-column: 2; margin: 0; text-align: center; }
136
+ .meshpad-panel[data-view="levels"] .meshpad-close { grid-column: 3; }
137
+ .meshpad-close { flex: 0 0 44px; }
138
+ .meshpad-levels {
139
+ flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; margin: 0 -4px; padding: 4px;
140
+ overflow-y: auto; overscroll-behavior: contain; touch-action: pan-y;
141
+ scrollbar-width: thin; scrollbar-color: #8c82c0 #211c49;
142
+ }
143
+ .meshpad-levels button {
144
+ flex: 0 0 auto; width: 100%; display: flex; align-items: center; justify-content: space-between;
145
+ gap: 12px; min-height: 64px; padding: 12px 14px; text-align: left;
146
+ }
147
+ .meshpad-levels button:first-child { margin-top: auto; }
148
+ .meshpad-levels button:last-child { margin-bottom: auto; }
149
+ .meshpad-level-info { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
150
+ .meshpad-level-number { font-size: 19px; line-height: 1.2; word-spacing: 2px; }
151
+ .meshpad-level-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 600; color: #d2c9ed; }
152
+ .meshpad-difficulty { flex-shrink: 0; padding: 5px 8px; border-radius: 8px; font-size: 12px; line-height: 1.2; color: #b6efcf; background: #233e3a; }
153
+ .meshpad-difficulty[data-difficulty="hard"] { color: #ffe6a0; background: #59451d; }
154
+ .meshpad-difficulty[data-difficulty="very-hard"] { color: #ffd1a8; background: #653a27; }
155
+ .meshpad-difficulty[data-difficulty="ultra-hard"] { color: #ffc1d8; background: #602b4c; }
156
+ .meshpad-levels [aria-current] { border-color: var(--meshpad-accent); background: #65538f; }
157
+ .meshpad-root [hidden] { display: none !important; }
158
+ @supports not (paint-order: stroke fill) {
159
+ .meshpad-level-label, .meshpad-resource-label, .meshpad-resource-value,
160
+ .meshpad-panel h2, .meshpad-result-detail, .meshpad-button-label,
161
+ .meshpad-level-number, .meshpad-level-name, .meshpad-difficulty {
162
+ -webkit-text-stroke: 0;
163
+ text-shadow: -1px -1px 0 var(--meshpad-text-outline), 1px -1px 0 var(--meshpad-text-outline), -1px 1px 0 var(--meshpad-text-outline), 1px 1px 0 var(--meshpad-text-outline), 0 3px 0 var(--meshpad-text-shadow);
164
+ }
165
+ }
166
+ /* Viewport fallback also supports browsers without container queries. */
167
+ @media (max-width: 340px) {
168
+ .meshpad-top { padding-inline: 8px; }
169
+ .meshpad-top nav { gap: 6px; padding: 6px; }
170
+ .meshpad-top h1 { font-size: 16px; }
171
+ .meshpad-top .meshpad-level-chooser { gap: 6px; padding-inline: 8px; }
172
+ .meshpad-resource-hearts { gap: 2px; padding-inline: 6px; }
173
+ .meshpad-heart { width: 14px; }
174
+ }
175
+ @media (max-height: 500px) {
176
+ .meshpad-top { padding: max(8px, env(safe-area-inset-top)) max(4px, env(safe-area-inset-left), env(safe-area-inset-right)) 8px; }
177
+ .meshpad-top nav { gap: 3px; padding: 4px 2px; border-radius: 16px; }
178
+ .meshpad-top h1 { font-size: 13px; }
179
+ .meshpad-top .meshpad-level-chooser { gap: 4px; padding-inline: 3px; }
180
+ .meshpad-level-label { word-spacing: 1px; }
181
+ .meshpad-top .meshpad-level-chooser svg { width: 10px; height: 10px; }
182
+ .meshpad-resource { min-width: 54px; padding-inline: 5px; }
183
+ .meshpad-resource-hearts { gap: 1px; padding-inline: 3px; }
184
+ .meshpad-heart { width: 8px; }
185
+ .meshpad-resource-value { font-size: 17px; }
186
+ .meshpad-hud-actions { gap: 3px; }
187
+ }
188
+ @container (max-width: 300px) {
189
+ .meshpad-top { padding-inline: 4px; }
190
+ .meshpad-top nav { gap: 3px; padding: 4px 2px; border-radius: 16px; }
191
+ .meshpad-top h1 { font-size: 13px; }
192
+ .meshpad-top .meshpad-level-chooser { gap: 4px; padding-inline: 3px; }
193
+ .meshpad-level-label { word-spacing: 1px; }
194
+ .meshpad-top .meshpad-level-chooser svg { width: 10px; height: 10px; }
195
+ .meshpad-resource { min-width: 54px; padding-inline: 5px; }
196
+ .meshpad-resource-hearts { gap: 1px; padding-inline: 3px; }
197
+ .meshpad-heart { width: 8px; }
198
+ .meshpad-resource-value { font-size: 17px; }
199
+ .meshpad-hud-actions { gap: 3px; }
200
+ }
201
+ @media (max-height: 460px), (max-width: 260px) {
202
+ .meshpad-top button { flex-basis: 32px; min-width: 32px; min-height: 36px; padding: 6px; }
203
+ .meshpad-top button svg { width: 18px; height: 18px; }
204
+ }
205
+ @container (max-width: 260px) {
206
+ .meshpad-top button { flex-basis: 32px; min-width: 32px; min-height: 36px; padding: 6px; }
207
+ .meshpad-top button svg { width: 18px; height: 18px; }
208
+ }
package/src/three.js ADDED
@@ -0,0 +1 @@
1
+ export * from 'three';
package/src/tooling.js ADDED
@@ -0,0 +1,128 @@
1
+ import { readFile, access, mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createServer, build, preview } from 'vite';
5
+ import { checkProject, resolveProject, watchProject } from './typecheck.js';
6
+
7
+ export const packageRoot = fileURLToPath(new URL('../', import.meta.url));
8
+ export const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
9
+
10
+ const escapeHTML = (text) => text.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
11
+
12
+ export function shell(entry, title = 'Meshpad') {
13
+ return `<!doctype html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="UTF-8" />
17
+ <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
18
+ <meta name="theme-color" content="#221e4e" />
19
+ <meta name="format-detection" content="telephone=no, date=no, address=no, email=no" />
20
+ <meta name="mobile-web-app-capable" content="yes" />
21
+ <meta name="apple-mobile-web-app-capable" content="yes" />
22
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
23
+ <link rel="icon" href="data:," />
24
+ <title>${escapeHTML(title)}</title>
25
+ </head>
26
+ <body><script type="module" src="${escapeHTML(entry)}"></script></body>
27
+ </html>`;
28
+ }
29
+
30
+ export async function projectConfig(entry = 'game.ts', options = {}) {
31
+ // Vite canonicalizes roots; the virtual HTML entry must use the same path.
32
+ const { root, source } = await resolveProject(entry, options);
33
+ const index = resolve(root, 'index.html');
34
+ const html = shell(`/@fs/${source.replaceAll('\\', '/')}`, options.title);
35
+ const outDir = resolve(root, options.outDir ?? 'dist');
36
+ const outPath = relative(root, outDir);
37
+ const sourceInOutput = relative(outDir, source);
38
+ if (!outPath || outPath.startsWith('..') || isAbsolute(outPath) || ['public', 'node_modules', '.git'].includes(outPath.split(/[\\/]/)[0]) || (!sourceInOutput.startsWith('..') && !isAbsolute(sourceInOutput))) {
39
+ throw new Error('Output must be a separate directory inside the project, away from the game source, public, node_modules, and .git.');
40
+ }
41
+ return {
42
+ root, configFile: false, base: './', publicDir: resolve(root, 'public'), logLevel: options.logLevel ?? 'info',
43
+ appType: 'spa',
44
+ resolve: {
45
+ // Also supports working on Meshpad's own example without installing itself.
46
+ alias: [
47
+ { find: /^meshpad$/, replacement: resolve(packageRoot, 'index.js') },
48
+ { find: /^meshpad\/three$/, replacement: resolve(packageRoot, 'src/three.js') },
49
+ { find: /^meshpad\/addons$/, replacement: resolve(packageRoot, 'src/addons.js') },
50
+ ],
51
+ dedupe: ['three'],
52
+ },
53
+ plugins: [{
54
+ name: 'meshpad-shell', enforce: 'pre',
55
+ resolveId(id) { if (id === index) return index; },
56
+ load(id) { if (id === index) return html; },
57
+ configureServer(server) {
58
+ server.middlewares.use(async (req, res, next) => {
59
+ const pathname = req.url?.split('?')[0];
60
+ if (pathname !== '/' && pathname !== '/index.html') return next();
61
+ try {
62
+ const result = await server.transformIndexHtml(req.url, html);
63
+ res.statusCode = 200; res.setHeader('Content-Type', 'text/html'); res.end(result);
64
+ } catch (error) { next(error); }
65
+ });
66
+ },
67
+ }],
68
+ optimizeDeps: { entries: [source] },
69
+ server: {
70
+ host: options.host ?? '127.0.0.1', port: options.port ?? 5173,
71
+ fs: { allow: [root, packageRoot, dirname(source)] },
72
+ },
73
+ preview: { host: options.host ?? '127.0.0.1', port: options.port ?? 4173 },
74
+ build: {
75
+ outDir, emptyOutDir: true,
76
+ target: ['es2022', 'safari15', 'chrome96'],
77
+ rolldownOptions: { input: index },
78
+ chunkSizeWarningLimit: 650,
79
+ },
80
+ };
81
+ }
82
+
83
+ export async function startDev(entry, options = {}) {
84
+ const config = await projectConfig(entry, options);
85
+ const { source } = await checkProject(entry, { root: config.root });
86
+ const server = await createServer(config);
87
+ watchProject(server, source, config.root);
88
+ try { await server.listen(); } catch (error) { await server.close(); throw error; }
89
+ return server;
90
+ }
91
+
92
+ export async function buildProject(entry, options = {}) {
93
+ const config = await projectConfig(entry, options);
94
+ await checkProject(entry, { root: config.root });
95
+ return build(config);
96
+ }
97
+
98
+ export async function previewProject(options = {}) {
99
+ const root = resolve(options.root ?? process.cwd());
100
+ const outDir = resolve(root, options.outDir ?? 'dist');
101
+ try { await access(resolve(outDir, 'index.html')); } catch { throw new Error('No built game found. Run "meshpad build" first.'); }
102
+ return preview({ root, configFile: false, build: { outDir }, preview: { host: options.host ?? '127.0.0.1', port: options.port ?? 4173 } });
103
+ }
104
+
105
+ export async function initProject(directory = '.', version = manifest.version) {
106
+ const root = resolve(directory);
107
+ const files = {
108
+ 'package.json': JSON.stringify({
109
+ name: root.split(/[\\/]/).at(-1).toLowerCase().replace(/[^a-z0-9-]/g, '-') || 'meshpad-game',
110
+ version: '0.0.0', private: true, type: 'module',
111
+ scripts: { dev: 'meshpad dev', build: 'meshpad build', preview: 'meshpad preview', render: 'meshpad render', typecheck: 'meshpad typecheck' },
112
+ engines: { node: '>=22.12.0' },
113
+ dependencies: { meshpad: `^${version}` },
114
+ }, null, 2) + '\n',
115
+ 'game.ts': await readFile(new URL('../templates/game.ts', import.meta.url), 'utf8'),
116
+ 'tsconfig.json': JSON.stringify({ extends: 'meshpad/tsconfig', include: ['**/*.ts'], exclude: ['node_modules', 'dist', 'renders'] }, null, 2) + '\n',
117
+ 'vercel.json': JSON.stringify({ $schema: 'https://openapi.vercel.sh/vercel.json', framework: null, buildCommand: 'npm run build', outputDirectory: 'dist' }, null, 2) + '\n',
118
+ '.gitignore': 'node_modules/\ndist/\nrenders/\n.vercel/\n.DS_Store\n',
119
+ };
120
+ for (const name of Object.keys(files)) {
121
+ let exists = false;
122
+ try { await access(resolve(root, name)); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
123
+ if (exists) throw new Error(`Refusing to overwrite ${resolve(root, name)}. Choose an empty directory.`);
124
+ }
125
+ await mkdir(root, { recursive: true });
126
+ for (const [name, content] of Object.entries(files)) await writeFile(resolve(root, name), content, { flag: 'wx' });
127
+ return root;
128
+ }
@@ -0,0 +1,88 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import ts from 'typescript';
5
+ import { stripVTControlCharacters } from 'node:util';
6
+
7
+ const packageRoot = fileURLToPath(new URL('../', import.meta.url));
8
+ const baseConfig = resolve(packageRoot, 'tsconfig.json');
9
+ const formatHost = {
10
+ getCurrentDirectory: () => process.cwd(),
11
+ getCanonicalFileName: (file) => file,
12
+ getNewLine: () => '\n',
13
+ };
14
+
15
+ export async function resolveProject(entry = 'game.ts', options = {}) {
16
+ const root = await realpath(resolve(options.root ?? process.cwd()));
17
+ const source = resolve(root, entry);
18
+ if (!source.endsWith('.ts') || source.endsWith('.d.ts')) {
19
+ throw new Error('Meshpad requires a TypeScript entry file (.ts). Rename your game to game.ts.');
20
+ }
21
+ let info;
22
+ try { info = await stat(source); } catch (error) {
23
+ if (error.code !== 'ENOENT') throw error;
24
+ throw new Error(`Game file not found: ${source}. Run "meshpad init" to create game.ts.`);
25
+ }
26
+ if (!info.isFile()) throw new Error(`Game entry must be a TypeScript file: ${source}.`);
27
+ return { root, source };
28
+ }
29
+
30
+ /** Check without emitting; Vite owns transpilation and bundling. */
31
+ export async function checkProject(entry, options = {}) {
32
+ const { root, source } = await resolveProject(entry, options);
33
+ const configPath = resolve(root, 'tsconfig.json');
34
+ const hasConfig = ts.sys.fileExists(configPath);
35
+ const config = ts.readConfigFile(hasConfig ? configPath : baseConfig, ts.sys.readFile);
36
+ if (config.error) throw new Error(ts.formatDiagnosticsWithColorAndContext([config.error], formatHost));
37
+ const raw = { ...config.config };
38
+ // Resolve our preset even when using the CLI from npx, outside an installed project.
39
+ const preset = (value) => value === 'meshpad/tsconfig' ? baseConfig : value;
40
+ if (raw.extends) raw.extends = Array.isArray(raw.extends) ? raw.extends.map(preset) : preset(raw.extends);
41
+ const parsed = ts.parseJsonConfigFileContent(raw, ts.sys, root, undefined, configPath);
42
+ if (parsed.errors.length) throw new Error(ts.formatDiagnosticsWithColorAndContext(parsed.errors, formatHost));
43
+ const compilerOptions = {
44
+ ...parsed.options,
45
+ noEmit: true, noCheck: false, allowJs: false,
46
+ paths: {
47
+ ...parsed.options.paths,
48
+ meshpad: [resolve(packageRoot, 'index.d.ts')],
49
+ 'meshpad/three': [resolve(packageRoot, 'three.d.ts')],
50
+ 'meshpad/addons': [resolve(packageRoot, 'addons.d.ts')],
51
+ },
52
+ };
53
+ const program = ts.createProgram({ rootNames: [...new Set([...parsed.fileNames, source])], options: compilerOptions });
54
+ const diagnostics = ts.getPreEmitDiagnostics(program);
55
+ if (diagnostics.length) throw new Error(`TypeScript check failed.\n${ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost)}`);
56
+ return { root, source };
57
+ }
58
+
59
+ export function watchProject(server, source, root) {
60
+ let timer;
61
+ let closed = false;
62
+ let hadError = false;
63
+ const changed = (path) => {
64
+ if (!path.endsWith('.ts') && !path.endsWith('.json')) return;
65
+ clearTimeout(timer);
66
+ timer = setTimeout(async () => {
67
+ try {
68
+ await checkProject(source, { root });
69
+ if (closed) return;
70
+ // Clear a previous error overlay, including errors in files not imported by Vite.
71
+ if (hadError) server.ws.send({ type: 'full-reload' });
72
+ hadError = false;
73
+ } catch (error) {
74
+ if (closed) return;
75
+ hadError = true;
76
+ server.config.logger.error(error.message);
77
+ server.ws.send({ type: 'error', err: { message: stripVTControlCharacters(error.message), stack: '', plugin: 'meshpad-typecheck' } });
78
+ }
79
+ }, 100);
80
+ };
81
+ const events = ['change', 'add', 'unlink'];
82
+ for (const event of events) server.watcher.on(event, changed);
83
+ server.httpServer.once('close', () => {
84
+ closed = true;
85
+ clearTimeout(timer);
86
+ for (const event of events) server.watcher.off(event, changed);
87
+ });
88
+ }