PageSourceSearch

https://fortune-cookie.tokyo/assets/js/cutin-overlay.js?v=1.0.610

js fortune-cookie.tokyo collected 2026-10-07 17:57:59 UTC 12,990 bytes, 312 lines download raw bytes

1/**
2 * 汎用カットインオーバーレイ
3 * spec: docs/features/battle-entry-cutin-popup/spec.md
4 *        docs/features/dokidoki-max-pull-double-push-cutin/spec.md(variant='bolt')
5 *
6 * window.playCutin(config, onComplete) を公開。
7 * config: { userName, iconUrl, tagline, soundKey, isSelf, variant? }
8 *   variant: 'band'(既定・帯型)| 'bolt'(落雷型。稲妻 canvas + シェイク)
9 * onComplete: カットイン CSS animation 終了時(animationend)に呼ばれる。setTimeout フォールバック付き。
10 *
11 * SE 発火は本モジュール内で一元化(呼び出し側は SE.play を呼ばない)。
12 * 鮮度・二重発火 dedup は呼び出し側(例: auction-room.js)の責務。
13 */
14(function () {
15    'use strict';
16
17    // CSS animation total = 2.4s。フォールバック timeout はマージン 200ms を持たせる。
18    // prefers-reduced-motion 環境ではアニメーションが無効化され animationend が発火しないため即時 reset。
19    var COMPLETE_FALLBACK_MS = 2600;
20    var REDUCED_MOTION_FALLBACK_MS = 100;
21    var BOLT_DECAY_MS = 300;
22    var SHAKE_DURATION_MS = 420;
23    var SHAKE_MAGNITUDE_PX = 14;
24
25    function isReducedMotion() {
26        return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
27    }
28
29    var container = null;
30    var playing = false;
31    var playTimeoutId = null;
32    var currentOnComplete = null;
33    var completeCalled = false;
34    var boltRafId = null;
35    var shakeRafId = null;
36
37    function initContainer() {
38        if (container) return container;
39        container = document.getElementById('cutin-overlay');
40        return container;
41    }
42
43    function clearBoltCanvas() {
44        var canvas = container && container.querySelector('.cutin-bolt');
45        if (!canvas) return;
46        var ctx = canvas.getContext && canvas.getContext('2d');
47        if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
48    }
49
50    function resetToIdle() {
51        if (playTimeoutId) {
52            clearTimeout(playTimeoutId);
53            playTimeoutId = null;
54        }
55        if (boltRafId) { cancelAnimationFrame(boltRafId); boltRafId = null; }
56        if (shakeRafId) { cancelAnimationFrame(shakeRafId); shakeRafId = null; }
57        clearBoltCanvas();
58        if (container) {
59            container.classList.remove('play');
60            container.classList.remove('variant-bolt');
61            container.style.display = 'none';
62            container.style.setProperty('--cutin-shake-x', '0px');
63            container.style.setProperty('--cutin-shake-y', '0px');
64        }
65        playing = false;
66    }
67
68    // ---- variant='bolt' 用: 稲妻描画(auction-room.js の AuctionDoublePushCutin を参考に独立実装。
69    //      auction 側のファイルは一切変更しない — spec §1.1) ----
70
71    function makeBoltPath(x1, y1, x2, y2, displace, segments) {
72        var pts = [{ x: x1, y: y1 }];
73        var dx = (x2 - x1) / segments, dy = (y2 - y1) / segments;
74        for (var i = 1; i < segments; i++) {
75            var t = i / segments;
76            var jitter = displace * (0.4 + t * 1.1);
77            pts.push({
78                x: x1 + dx * i + (Math.random() - 0.5) * jitter,
79                y: y1 + dy * i + (Math.random() - 0.5) * jitter * 0.25
80            });
81        }
82        pts.push({ x: x2, y: y2 });
83        return pts;
84    }
85
86    function tracePathsBatch(ctx, bolts) {
87        ctx.beginPath();
88        for (var i = 0; i < bolts.length; i++) {
89            var pts = bolts[i];
90            ctx.moveTo(pts[0].x, pts[0].y);
91            for (var j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x, pts[j].y);
92        }
93        ctx.stroke();
94    }
95
96    function drawBolts(el) {
97        var canvas = el.querySelector('.cutin-bolt');
98        if (!canvas) return;
99        var ctx = canvas.getContext('2d');
100        if (!ctx) return;
101
102        var dpr = Math.min(window.devicePixelRatio || 1, 2);
103        var w = window.innerWidth;
104        var h = window.innerHeight;
105        canvas.width = Math.floor(w * dpr);
106        canvas.height = Math.floor(h * dpr);
107        canvas.style.width = w + 'px';
108        canvas.style.height = h + 'px';
109        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
110
111        // 原点はアイコン中心(bolt 用 keyframes は 0% を中央位置から開始するため、この時点で座標が確定している)
112        var iconEl = el.querySelector('.cutin-icon');
113        var ox = w / 2, oy = h * 0.42;
114        if (iconEl) {
115            var rect = iconEl.getBoundingClientRect();
116            if (rect.width && rect.height) {
117                ox = rect.left + rect.width / 2;
118                oy = rect.top + rect.height / 2;
119            }
120        }
121
122        var bolts = [];
123        var boltCount = 8;
124        var baseAngle = Math.random() * Math.PI * 2;
125        for (var i = 0; i < boltCount; i++) {
126            var angle = baseAngle + (i / boltCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.4;
127            var length = 280 + Math.random() * 340;
128            var tx = ox + Math.cos(angle) * length;
129            var ty = oy + Math.sin(angle) * length;
130            bolts.push(makeBoltPath(ox, oy, tx, ty, 55, 10));
131            if (Math.random() < 0.3) {
132                var last = bolts[bolts.length - 1];
133                var gp = last[Math.floor(last.length / 2)];
134                var subAngle = angle + (Math.random() - 0.5) * 1.2;
135                var subLen = 90 + Math.random() * 130;
136                bolts.push(makeBoltPath(gp.x, gp.y, gp.x + Math.cos(subAngle) * subLen, gp.y + Math.sin(subAngle) * subLen, 40, 6));
137            }
138        }
139
140        var startTime = performance.now();
141        function frame(now) {
142            var elapsed = now - startTime;
143            ctx.clearRect(0, 0, w, h);
144            var alpha = Math.max(0, 1 - elapsed / BOLT_DECAY_MS);
145            alpha = Math.pow(alpha, 0.7) * (0.55 + Math.random() * 0.45);
146            ctx.lineCap = 'round';
147            ctx.lineJoin = 'round';
148            ctx.strokeStyle = 'rgba(140,190,255,' + (alpha * 0.55) + ')';
149            ctx.lineWidth = 38;
150            ctx.shadowColor = '#7db4ff';
151            ctx.shadowBlur = 42;
152            tracePathsBatch(ctx, bolts);
153            ctx.strokeStyle = 'rgba(255,255,255,' + alpha + ')';
154            ctx.lineWidth = 11;
155            ctx.shadowBlur = 18;
156            tracePathsBatch(ctx, bolts);
157            ctx.shadowBlur = 0;
158            if (elapsed < BOLT_DECAY_MS) {
159                boltRafId = requestAnimationFrame(frame);
160            } else {
161                ctx.clearRect(0, 0, w, h);
162                boltRafId = null;
163            }
164        }
165        boltRafId = requestAnimationFrame(frame);
166    }
167
168    function startShake(el) {
169        var start = performance.now();
170        function step(now) {
171            var t = (now - start) / SHAKE_DURATION_MS;
172            if (t >= 1) {
173                el.style.setProperty('--cutin-shake-x', '0px');
174                el.style.setProperty('--cutin-shake-y', '0px');
175                shakeRafId = null;
176                return;
177            }
178            var damp = 1 - t;
179            el.style.setProperty('--cutin-shake-x', ((Math.random() - 0.5) * SHAKE_MAGNITUDE_PX * damp * 2).toFixed(1) + 'px');
180            el.style.setProperty('--cutin-shake-y', ((Math.random() - 0.5) * SHAKE_MAGNITUDE_PX * damp * 2).toFixed(1) + 'px');
181            shakeRafId = requestAnimationFrame(step);
182        }
183        shakeRafId = requestAnimationFrame(step);
184    }
185
186    function fireComplete() {
187        if (completeCalled) return;
188        completeCalled = true;
189        var cb = currentOnComplete;
190        currentOnComplete = null;
191        if (typeof cb === 'function') {
192            try { cb(); } catch (e) { /* callback 失敗は握り潰す */ }
193        }
194    }
195
196    function applyColors(el, fixedHue) {
197        var hue = (typeof fixedHue === 'number') ? fixedHue : Math.floor(Math.random() * 360);
198        var style = el.style;
199        style.setProperty('--cutin-hot',  'hsl(' + hue + ', 100%, 60%)');
200        style.setProperty('--cutin-glow', 'hsla(' + hue + ', 100%, 55%, 0.78)');
201        style.setProperty('--cutin-volt', 'hsl(' + ((hue + 15) % 360) + ', 100%, 80%)');
202    }
203
204    function playCutin(config, onComplete) {
205        config = config || {};
206        var el = initContainer();
207        if (!el) {
208            if (typeof onComplete === 'function') onComplete();
209            return;
210        }
211
212        if (playing) {
213            fireComplete();
214            resetToIdle();
215        }
216        currentOnComplete = onComplete || null;
217        completeCalled = false;
218
219        // ⚠️ variant の付与は上の resetToIdle() より必ず後に置く。先に付けると、直前の再生を
220        // 割り込んだときに resetToIdle() が直後に剥がしてしまい、割り込み再生のときだけ
221        // bolt が無音で band になる(devils-advocate 指摘)。
222        var isBolt = config.variant === 'bolt';
223        el.classList.toggle('variant-bolt', isBolt);
224
225        var headlineEl = el.querySelector('.cutin-headline');
226        if (headlineEl) {
227            if (Array.isArray(config.taglineLines) && config.taglineLines.length > 0) {
228                // 折り返し単位を呼び出し側で指定する(各セグメントは inline-block、モバイルでは 1 行ずつ)。
229                // textContent で入れるので HTML は解釈されない(XSS 対策は tagline と同等)
230                headlineEl.textContent = '';
231                config.taglineLines.forEach(function (line) {
232                    var seg = document.createElement('span');
233                    seg.className = 'cutin-headline__seg';
234                    seg.textContent = String(line);
235                    headlineEl.appendChild(seg);
236                });
237            } else {
238                headlineEl.textContent = config.tagline ? String(config.tagline) : '';
239            }
240        }
241
242        var nameEl = el.querySelector('.cutin-name');
243        if (nameEl) {
244            nameEl.textContent = config.userName ? String(config.userName) : '';
245        }
246
247        var imgEl = el.querySelector('.cutin-icon-img');
248        if (imgEl) {
249            var src = config.iconUrl ? String(config.iconUrl) : '';
250            imgEl.src = src;
251            imgEl.alt = config.userName ? String(config.userName) : '';
252            imgEl.style.display = src ? '' : 'none';
253        }
254
255        var youBadgeEl = el.querySelector('.cutin-you-badge');
256        if (youBadgeEl) {
257            youBadgeEl.hidden = !config.isSelf;
258        }
259
260        applyColors(el, (typeof config.fixedHue === 'number') ? config.fixedHue : null);
261
262        if (config.soundKey && window.SE && typeof window.SE.play === 'function') {
263            try {
264                // 初回デプロイ後の SE 診断ログ(本 PR マージ後に条件付きに戻す)
265                var cfg = (window.SE_CONFIG || {}).files || {};
266                var hasUrl = !!cfg[config.soundKey];
267                console.log('[cutin] SE.play soundKey=' + config.soundKey + ' hasUrl=' + hasUrl + (hasUrl ? ' url=' + cfg[config.soundKey].substring(0, 80) : ''));
268                window.SE.play(String(config.soundKey));
269            } catch (e) { console.warn('[cutin] SE.play threw', e); }
270        } else if (config.soundKey) {
271            console.warn('[cutin] SE not available for soundKey=' + config.soundKey, {hasSE: !!window.SE, hasPlay: !!(window.SE && window.SE.play)});
272        }
273
274        playing = true;
275        el.style.display = 'block';
276        el.classList.remove('play');
277        void el.offsetWidth;
278        el.classList.add('play');
279
280        var reduced = isReducedMotion();
281        // .cutin-bolt が無い場合(他ページで古いバージョンの <x-cutin-overlay> が読まれた場合等)は
282        // 稲妻もシェイクも行わない。drawBolts() 単体の早期 return だけだと、シェイクだけ帯が
283        // 揺れる中途半端な状態になる(code-refactor-reviewer 指摘)。
284        if (isBolt && !reduced && el.querySelector('.cutin-bolt')) {
285            try { drawBolts(el); startShake(el); } catch (e) { /* 表示専用機能で抽選を止めない */ }
286        }
287
288        var fallbackMs = reduced ? REDUCED_MOTION_FALLBACK_MS : COMPLETE_FALLBACK_MS;
289        playTimeoutId = setTimeout(function () {
290            fireComplete();
291            resetToIdle();
292        }, fallbackMs);
293
294        var nameWrap = el.querySelector('.cutin-name-wrap');
295        if (nameWrap) {
296            var handleEnd = function () {
297                nameWrap.removeEventListener('animationend', handleEnd);
298                fireComplete();
299            };
300            nameWrap.addEventListener('animationend', handleEnd, { once: true });
301        }
302    }
303
304    window.addEventListener('pageshow', function (e) {
305        if (e && e.persisted) {
306            fireComplete();
307            resetToIdle();
308        }
309    });
310
311    window.playCutin = playCutin;
312}());

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.