asobi 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.
@@ -0,0 +1,295 @@
1
+ /* asobi player: draws a "Reel" (see DESIGN.md section 9) into an element.
2
+ * Vanilla JS, no dependencies. Usage:
3
+ * const player = AsobiPlayer.mount(element, { controls: true, keys: true, language: "auto" });
4
+ * player.update(reel); // swap the picture; the clock keeps running
5
+ * player.destroy();
6
+ */
7
+ (function (global) {
8
+ "use strict";
9
+
10
+ var SVG_NS = "http://www.w3.org/2000/svg";
11
+ var HIDE_AFTER_MS = 2500;
12
+ var MAX_SIDE = 1080; // longest side of a recording, in pixels
13
+
14
+ var TEXT = {
15
+ en: { record: "record", stop: "stop", code: "code", noRecorder: "Recording is not available in this browser." },
16
+ ja: { record: "録画", stop: "停止", code: "コード", noRecorder: "このブラウザでは録画できません。" }
17
+ };
18
+
19
+ var CSS = [
20
+ ".asobi-root{position:relative;width:100%;height:100%;overflow:hidden;background:white;",
21
+ " font-family:system-ui,-apple-system,sans-serif}",
22
+ ".asobi-root>svg.asobi-svg{position:absolute;left:0;top:0;width:100%;height:100%;display:block}",
23
+ ".asobi-controls{position:absolute;top:10px;right:10px;display:flex;gap:6px;align-items:center;",
24
+ " opacity:0;pointer-events:none;transition:opacity .4s}",
25
+ ".asobi-controls.asobi-on{opacity:1;pointer-events:auto}",
26
+ ".asobi-btn{font:12px/1 system-ui,-apple-system,sans-serif;color:#fff;background:rgba(20,20,24,.55);",
27
+ " border:1px solid rgba(255,255,255,.35);border-radius:999px;padding:6px 11px;cursor:pointer}",
28
+ ".asobi-btn:hover{background:rgba(20,20,24,.8)}",
29
+ ".asobi-note{font:12px/1.3 system-ui,sans-serif;color:#fff;background:rgba(20,20,24,.75);",
30
+ " border-radius:6px;padding:5px 9px}",
31
+ ".asobi-recdot{position:absolute;top:12px;left:12px;width:12px;height:12px;border-radius:50%;",
32
+ " background:#e5262b;box-shadow:0 0 0 2px rgba(255,255,255,.8);display:none;",
33
+ " animation:asobi-blink 1.2s ease-in-out infinite}",
34
+ ".asobi-recdot.asobi-on{display:block}",
35
+ "@keyframes asobi-blink{50%{opacity:.35}}",
36
+ ".asobi-code{position:absolute;left:0;top:0;right:0;bottom:0;margin:0;padding:48px 20px 20px;overflow:auto;",
37
+ " box-sizing:border-box;display:none;background:none;color:#f4f4f6;",
38
+ " font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre;tab-size:2}",
39
+ ".asobi-code.asobi-on{display:block}",
40
+ ".asobi-line{display:block;width:max-content;min-height:1.5em;padding:0 8px;",
41
+ " background:rgba(14,14,20,.72);text-shadow:0 1px 2px rgba(0,0,0,.6)}",
42
+ ".asobi-line.asobi-blank{background:none}",
43
+ ".asobi-error{position:absolute;left:0;right:0;bottom:0;max-height:38%;overflow:auto;box-sizing:border-box;",
44
+ " margin:0;padding:12px 18px;display:none;background:rgba(150,20,30,.93);color:#fff;",
45
+ " font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}",
46
+ ".asobi-error.asobi-on{display:block}"
47
+ ].join("\n");
48
+
49
+ function injectStyle() {
50
+ if (document.getElementById("asobi-style")) return;
51
+ var s = document.createElement("style");
52
+ s.id = "asobi-style";
53
+ s.textContent = CSS;
54
+ (document.head || document.documentElement).appendChild(s);
55
+ }
56
+
57
+ function el(tag, cls, parent) {
58
+ var e = document.createElement(tag);
59
+ if (cls) e.className = cls;
60
+ if (parent) parent.appendChild(e);
61
+ return e;
62
+ }
63
+
64
+ function validReel(r) {
65
+ return r && Array.isArray(r.frames) && r.frames.length > 0;
66
+ }
67
+
68
+ function pickMime() {
69
+ if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return null;
70
+ var list = ["video/mp4;codecs=avc1", "video/mp4", "video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"];
71
+ for (var i = 0; i < list.length; i++) if (MediaRecorder.isTypeSupported(list[i])) return list[i];
72
+ return null;
73
+ }
74
+
75
+ function mount(element, options) {
76
+ options = options || {};
77
+ var useControls = options.controls !== false;
78
+ var useKeys = options.keys !== false;
79
+ var lang = options.language || "auto";
80
+ if (lang === "auto") lang = /^ja/i.test((global.navigator && navigator.language) || "") ? "ja" : "en";
81
+ var T = TEXT[lang] || TEXT.en;
82
+
83
+ injectStyle();
84
+
85
+ // ---- DOM ---------------------------------------------------------
86
+ var root = el("div", "asobi-root");
87
+ element.appendChild(root);
88
+
89
+ var svg = document.createElementNS(SVG_NS, "svg");
90
+ svg.setAttribute("class", "asobi-svg");
91
+ svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
92
+ svg.setAttribute("stroke-linejoin", "round");
93
+ svg.setAttribute("stroke-linecap", "round");
94
+ var bgRect = document.createElementNS(SVG_NS, "rect"); // background, also used when recording
95
+ var content = document.createElementNS(SVG_NS, "g");
96
+ svg.appendChild(bgRect);
97
+ svg.appendChild(content);
98
+ root.appendChild(svg);
99
+
100
+ var codeBox = el("pre", "asobi-code", root);
101
+ var errorBox = el("pre", "asobi-error", root);
102
+ var recDot = el("div", "asobi-recdot", root);
103
+ var controls = el("div", "asobi-controls", root);
104
+ var note = el("span", "asobi-note", controls);
105
+ note.style.display = "none";
106
+ var recBtn = el("button", "asobi-btn", controls);
107
+ var codeBtn = el("button", "asobi-btn", controls);
108
+ recBtn.type = codeBtn.type = "button";
109
+ recBtn.textContent = "● " + T.record;
110
+ codeBtn.textContent = "</> " + T.code;
111
+ if (!useControls) controls.style.display = "none";
112
+
113
+ // ---- state -------------------------------------------------------
114
+ var reel = null; // last good reel
115
+ var reelId = 0; // bumps on every good reel
116
+ var t0 = performance.now(); // the clock: never reset by update()
117
+ var shown = { id: -1, idx: -1 };
118
+ var raf = 0, destroyed = false;
119
+ var hideTimer = 0, noteTimer = 0;
120
+
121
+ function frameIndex(now) {
122
+ var fps = Number(reel.fps) > 0 ? Number(reel.fps) : 1;
123
+ return Math.floor(((now - t0) / 1000) * fps) % reel.frames.length;
124
+ }
125
+
126
+ // The code goes on the picture line by line, each line with its own
127
+ // tight backing (like subtitles), so the picture stays visible.
128
+ function setCode(text) {
129
+ while (codeBox.firstChild) codeBox.removeChild(codeBox.firstChild);
130
+ if (typeof text !== "string" || text === "") return;
131
+ text.replace(/\n$/, "").split("\n").forEach(function (line) {
132
+ var row = el("span", "asobi-line" + (line.trim() === "" ? " asobi-blank" : ""), codeBox);
133
+ row.textContent = line;
134
+ });
135
+ }
136
+
137
+ function viewOf(r) {
138
+ var v = r.view;
139
+ return Array.isArray(v) && v.length === 4 && v[2] > 0 && v[3] > 0 ? v : [0, 0, 100, 100];
140
+ }
141
+
142
+ function applyReel() {
143
+ var v = viewOf(reel), bg = reel.background || "white";
144
+ svg.setAttribute("viewBox", v.join(" "));
145
+ root.style.background = bg;
146
+ bgRect.setAttribute("x", v[0]); bgRect.setAttribute("y", v[1]);
147
+ bgRect.setAttribute("width", v[2]); bgRect.setAttribute("height", v[3]);
148
+ bgRect.setAttribute("fill", bg);
149
+ setCode(reel.code);
150
+ }
151
+
152
+ // ---- render loop: touch the DOM only when frame or reel changed --
153
+ function tick(now) {
154
+ if (destroyed) return;
155
+ if (reel) {
156
+ var idx = frameIndex(now);
157
+ if (shown.id !== reelId || shown.idx !== idx) {
158
+ if (shown.id !== reelId) applyReel();
159
+ content.innerHTML = reel.frames[idx];
160
+ shown.id = reelId; shown.idx = idx;
161
+ }
162
+ }
163
+ raf = requestAnimationFrame(tick);
164
+ }
165
+ raf = requestAnimationFrame(tick);
166
+
167
+ // ---- update ------------------------------------------------------
168
+ function update(next) {
169
+ if (destroyed || !next) return;
170
+ if (typeof next.error === "string" && next.error !== "") {
171
+ errorBox.textContent = next.error;
172
+ errorBox.classList.add("asobi-on");
173
+ if (typeof next.code === "string") setCode(next.code);
174
+ return; // keep the last good picture
175
+ }
176
+ errorBox.classList.remove("asobi-on");
177
+ if (validReel(next)) { reel = next; reelId++; }
178
+ }
179
+
180
+ // ---- controls visibility ----------------------------------------
181
+ function wake() {
182
+ if (!useControls) return;
183
+ controls.classList.add("asobi-on");
184
+ clearTimeout(hideTimer);
185
+ hideTimer = setTimeout(function () {
186
+ if (!controls.matches(":hover")) controls.classList.remove("asobi-on");
187
+ else wake();
188
+ }, HIDE_AFTER_MS);
189
+ }
190
+ root.addEventListener("mousemove", wake);
191
+ root.addEventListener("touchstart", wake, { passive: true });
192
+
193
+ // ---- code overlay ------------------------------------------------
194
+ function toggleCode() { codeBox.classList.toggle("asobi-on"); }
195
+ codeBtn.addEventListener("click", toggleCode);
196
+
197
+ // ---- recording ---------------------------------------------------
198
+ var rec = null; // { recorder, timer, chunks, mime }
199
+
200
+ function say(msg) {
201
+ note.textContent = msg; note.style.display = "";
202
+ wake();
203
+ clearTimeout(noteTimer);
204
+ noteTimer = setTimeout(function () { note.style.display = "none"; }, 4000);
205
+ }
206
+
207
+ // Serialise the current SVG (background included) at pixel size w x h.
208
+ function svgSource(w, h) {
209
+ var clone = svg.cloneNode(true);
210
+ clone.setAttribute("xmlns", SVG_NS);
211
+ clone.setAttribute("width", w);
212
+ clone.setAttribute("height", h);
213
+ clone.removeAttribute("class");
214
+ return new XMLSerializer().serializeToString(clone);
215
+ }
216
+
217
+ function startRecording() {
218
+ var mime = pickMime();
219
+ if (!mime || !reel) { say(T.noRecorder); return; }
220
+ var v = viewOf(reel), scale = MAX_SIDE / Math.max(v[2], v[3]);
221
+ var w = Math.max(2, Math.round(v[2] * scale / 2) * 2), h = Math.max(2, Math.round(v[3] * scale / 2) * 2);
222
+ var canvas = document.createElement("canvas");
223
+ canvas.width = w; canvas.height = h;
224
+ var ctx = canvas.getContext("2d");
225
+ var fps = Number(reel.fps) >= 10 ? Math.min(Number(reel.fps), 60) : 30;
226
+ var stream = canvas.captureStream(fps);
227
+ var recorder;
228
+ try { recorder = new MediaRecorder(stream, { mimeType: mime }); }
229
+ catch (e) { say(T.noRecorder); return; }
230
+ var state = { recorder: recorder, chunks: [], mime: mime, timer: 0, busy: false };
231
+ recorder.ondataavailable = function (e) { if (e.data && e.data.size) state.chunks.push(e.data); };
232
+ recorder.onstop = function () {
233
+ var ext = /mp4/.test(mime) ? "mp4" : "webm";
234
+ var blob = new Blob(state.chunks, { type: mime.split(";")[0] });
235
+ var a = document.createElement("a");
236
+ a.href = URL.createObjectURL(blob);
237
+ a.download = "asobi." + ext;
238
+ document.body.appendChild(a); a.click(); a.remove();
239
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
240
+ };
241
+ // Draw the frame that is on screen now. Skip a tick if the last image is still loading.
242
+ function draw() {
243
+ if (state.busy) return;
244
+ state.busy = true;
245
+ var img = new Image();
246
+ img.onload = function () { ctx.drawImage(img, 0, 0, w, h); state.busy = false; };
247
+ img.onerror = function () { state.busy = false; };
248
+ img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svgSource(w, h));
249
+ }
250
+ draw();
251
+ state.timer = setInterval(draw, 1000 / fps);
252
+ recorder.start();
253
+ rec = state;
254
+ recDot.classList.add("asobi-on");
255
+ recBtn.textContent = "■ " + T.stop;
256
+ }
257
+
258
+ function stopRecording() {
259
+ if (!rec) return;
260
+ clearInterval(rec.timer);
261
+ if (rec.recorder.state !== "inactive") rec.recorder.stop();
262
+ rec = null;
263
+ recDot.classList.remove("asobi-on");
264
+ recBtn.textContent = "● " + T.record;
265
+ }
266
+
267
+ function toggleRecording() { rec ? stopRecording() : startRecording(); }
268
+ recBtn.addEventListener("click", toggleRecording);
269
+
270
+ // ---- keys --------------------------------------------------------
271
+ function onKey(e) {
272
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
273
+ var t = e.target && e.target.tagName;
274
+ if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT" || (e.target && e.target.isContentEditable)) return;
275
+ if (e.key === "c" || e.key === "C") toggleCode();
276
+ else if (e.key === "r" || e.key === "R") toggleRecording();
277
+ }
278
+ if (useKeys) document.addEventListener("keydown", onKey);
279
+
280
+ return {
281
+ update: update,
282
+ destroy: function () {
283
+ if (destroyed) return;
284
+ stopRecording();
285
+ destroyed = true;
286
+ cancelAnimationFrame(raf);
287
+ clearTimeout(hideTimer); clearTimeout(noteTimer);
288
+ document.removeEventListener("keydown", onKey);
289
+ root.remove();
290
+ }
291
+ };
292
+ }
293
+
294
+ global.AsobiPlayer = { mount: mount };
295
+ })(typeof window !== "undefined" ? window : this);
data/lib/asobi.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Everything: the core, to_art on Ruby's own objects, and the shape words
4
+ # available everywhere.
5
+ require_relative "asobi/pure"
6
+ require_relative "asobi/core_ext"
7
+ require_relative "asobi/top_level"
8
+
9
+ %w[motion reel sketch].each do |part|
10
+ path = File.join(__dir__, "asobi", "#{part}.rb")
11
+ require path if File.exist?(path)
12
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: asobi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Hayato1031
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-11-11 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: asobi (play) treats every Ruby object as something that can become a
14
+ picture, and every picture as an object you can repeat, arrange, combine and set
15
+ in motion. Run a sketch with plain ruby and it plays live in your browser.
16
+ email:
17
+ executables:
18
+ - asobi
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - DESIGN.md
23
+ - LICENSE
24
+ - README.ja.md
25
+ - README.md
26
+ - docs/first-sketch.svg
27
+ - exe/asobi
28
+ - lib/asobi.rb
29
+ - lib/asobi/art.rb
30
+ - lib/asobi/color.rb
31
+ - lib/asobi/core_ext.rb
32
+ - lib/asobi/geometry.rb
33
+ - lib/asobi/live.rb
34
+ - lib/asobi/many.rb
35
+ - lib/asobi/messages.rb
36
+ - lib/asobi/motion.rb
37
+ - lib/asobi/nodes.rb
38
+ - lib/asobi/pure.rb
39
+ - lib/asobi/random.rb
40
+ - lib/asobi/reel.rb
41
+ - lib/asobi/shapes.rb
42
+ - lib/asobi/sketch.rb
43
+ - lib/asobi/svg.rb
44
+ - lib/asobi/top_level.rb
45
+ - lib/asobi/version.rb
46
+ - lib/asobi/web/live.html
47
+ - lib/asobi/web/player.js
48
+ homepage: https://github.com/Hayato1031/asobi
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ source_code_uri: https://github.com/Hayato1031/asobi
53
+ bug_tracker_uri: https://github.com/Hayato1031/asobi/issues
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.2'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.5.11
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: 'Media art in Ruby: objects become pictures, and pictures are objects.'
73
+ test_files: []