// ドリームテーゼ - 盤面・キャラクター画面コンポーネント // CSS アニメーション注入 (function () { if (document.getElementById('dt-board-style')) return; var s = document.createElement('style'); s.id = 'dt-board-style'; s.textContent = [ // スポットライト用アニメーション可能なカスタムプロパティ定義 '@property --dt-spot-size { syntax: ""; initial-value: 1.5%; inherits: false; }', '@property --dt-outer-stop { syntax: ""; initial-value: 5.5%; inherits: false; }', '@property --dt-spot-x { syntax: ""; initial-value: 50%; inherits: false; }', '@property --dt-spot-y { syntax: ""; initial-value: 50%; inherits: false; }', '@property --dt-spot-alpha { syntax: ""; initial-value: 1; inherits: false; }', '@keyframes tokenGlowPulse {', ' 0%,100%{ filter: drop-shadow(0 0 3px rgba(124,58,237,1)) drop-shadow(0 0 6px rgba(124,58,237,0.8)); }', ' 50% { filter: drop-shadow(0 0 8px rgba(167,139,250,1)) drop-shadow(0 0 16px rgba(124,58,237,0.8)); }', '}', '@keyframes costGlowPulse {', ' 0%,100%{ filter: drop-shadow(0 0 3px rgba(80,20,180,0.8)) drop-shadow(0 0 6px rgba(80,20,180,0.6)); }', ' 50% { filter: drop-shadow(0 0 8px rgba(120,60,240,1)) drop-shadow(0 0 16px rgba(100,40,210,0.9)); }', '}', '@keyframes dimFadeIn { from { opacity: 0; } to { opacity: 1; } }', '@keyframes opRueReveal { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0% 0); } }', '@keyframes opRueWhispIn { from { opacity: 0; transform: scale(0.88) translateY(24px); } to { opacity: 1; transform: scale(1) translateY(0); } }', '@keyframes opRueFadeIn { from { opacity: 0; } to { opacity: 1; } }', ].join(''); document.head.appendChild(s); }()); /* ============================================================== カード画像マッピング ============================================================== */ var CARD_IMAGES = { '威風': 'assets/card_威風.webp', '判断': 'assets/card_判断.webp', '宣誓': 'assets/card_宣誓.webp', '護法斬り': 'assets/card_護法斬り.webp', '闇纏い': 'assets/card_闇纏い.webp', '大上段': 'assets/card_大上段.webp', '踏み込み': 'assets/card_踏み込み.webp', 'ステラ・ルプトゥラ': 'assets/card_ステラルプトゥラ.webp', '二連撃': 'assets/card_二連撃.webp', '自我': 'assets/card_自我.webp', 'moment_a': 'assets/card_moment_a.webp', 'moment_b': 'assets/card_moment_b.webp', }; /* ============================================================== トークン配置定義 ============================================================== */ // TOKEN_W/TOKEN_H はボードの幅/高さに対する%。 // トークン画像は正方形なので、ピクセル上で正方形に見えるよう // ボードの縦横比 (2167/1588 ≈ 1.364) に合わせて W を調整している。 var TOKEN_W = 4.35; // ≈ 3.2% × (board_h/board_w) → ピクセル上でほぼ正方形 var TOKEN_H = 3.2; // ボード高さの 3.2% // ダイヤモンド配置: [x%, y%, flipped] // ^ row0: col1 // ^v^ row1: col0,1,2 // v^v row2: col0,1,2 // v row3: col1 // (row+col) が偶数 → v (flipped=true), 奇数 → ^ (flipped=false) function genAwareGrid(sx, sy, gx, gy) { var layout = [[1], [0, 1, 2], [0, 1, 2], [1]]; var pos = []; for (var row = 0; row < layout.length; row++) { for (var c = 0; c < layout[row].length; c++) { var col = layout[row][c]; pos.push([sx + col * gx, sy + row * gy, (row + col) % 2 === 0]); } } return pos; } // opponent = 画面上、player = 画面下(ミラー+向き反転) var OPPONENT_POS = genAwareGrid(1.5, 1.0, 2.95, 3.5); var PLAYER_POS = OPPONENT_POS.map(function (p) { return [100 - p[0] - TOKEN_W, 100 - p[1] - TOKEN_H, !p[2]]; }); // 上端中央(opponent) / 下端中央(player) の全黒トークン群 // v row0: col1 // vvv row1: col1,2,3 // vvvv row2: col0,1,2,3 // gx=2.95 で4列幅≈13.05% → 中央揃え sx≈43.5 var ALTER_OPPONENT_POS = (function () { var layout = [[0, 1, 2, 3], [0, 1, 2], [2]]; var gx = 5.6, gy = 3.5, sx = 41.2, sy = 1.1, pos = []; for (var row = 0; row < layout.length; row++) { for (var c = 0; c < layout[row].length; c++) { var col = layout[row][c]; pos.push([sx + col * gx, sy + row * gy]); } } return pos; }()); var ALTER_PLAYER_POS = ALTER_OPPONENT_POS.map(function (p) { return [100 - p[0] - TOKEN_W, 100 - p[1] - TOKEN_H]; }); var SLOTS = { opponent: [ { left: 70.5, top: 28, w: 12.5, h: 16.8 }, { left: 47.5, top: 28, w: 12.5, h: 16.8 }, { left: 24.5, top: 29.3, w: 12.5, h: 16.8 }, ], player: [ { left: 59, top: 55, w: 12.5, h: 16.8 }, { left: 36, top: 53.7, w: 12.5, h: 16.8 }, { left: 13, top: 55, w: 12.5, h: 16.8 }, ], }; // カードのアスペクト比(幅/高さ)と、ボードの高さ/幅比。 // これを使ってカードの実際の幅(ボード幅%)を計算し、MagicDotsをカード中央に配置する。 // カードは height: sl.h% (ボード高さ基準) でレンダリングされるため、 // 実際の幅(ボード幅%) = CARD_ASPECT × sl.h × BOARD_HW var CARD_ASPECT = 5 / 7; // カード幅/高さ比(標準TCG 5:7) var BOARD_HW = 2167 / 1588; // ボード高さ/幅比(aspectRatio: 1588/2167 より) // スロットのカード中心X座標(ボード幅%)を返す function cardCenterX(sl) { return sl.left + CARD_ASPECT * sl.h * BOARD_HW / 2; } // top のみ定義。X位置は SLOTS[side][i] から cardCenterX() で自動計算。 // opponent guardian は player の対応スロット上に配置 var GUARDIAN_SLOTS = { player: [ { top: 50 }, { top: 50 }, { top: 50 }, ], opponent: [ { top: 47 }, { top: 47 }, { top: 47 }, ], }; // reinforcement: player は自分スロット下部、opponent は自分スロット上部 var REINFORCEMENT_SLOTS = { player: [ { top: 72.5 }, { top: 72.5 }, { top: 72.5 }, ], opponent: [ { top: 23 }, { top: 23 }, { top: 23 }, ], }; // 夢遊魔力トークン階段配置パラメータ // v^v // v^v // v^v // v^v var DREAM_GX = TOKEN_W - 1.2; // 水平ステップ(board幅%) var DREAM_GY = TOKEN_H + 0; // 垂直ステップ(board高さ%) var DREAM_BASE_X = 4; // 最左列 left 基準(opponent) var DREAM_BASE_Y = 11; // 最上行 top 基準(opponent) // i 番目(0始まり)のトークン位置と向きを返す // side='opponent': 左上エリア / side='player': 右下エリア(点対称) function dreamPos(i, side) { var r = Math.floor(i / 3); // 行(0=底辺, 3=頂辺) var c = i % 3; // 列(0=左, 1=中, 2=右) var left, top, rot; if (side !== 'player') { left = DREAM_BASE_X + (r + c) * DREAM_GX; top = DREAM_BASE_Y + (3 - r) * DREAM_GY; rot = c % 2 === 0 ? 'rotate(180deg)' : undefined; // v^v } else { left = 100 - (DREAM_BASE_X + (r + c) * DREAM_GX) - TOKEN_W; top = 100 - (DREAM_BASE_Y + (3 - r) * DREAM_GY) - TOKEN_H; rot = c % 2 === 0 ? undefined : 'rotate(180deg)'; // 点対称で反転 } return { left: left, top: top, rot: rot }; } /* ============================================================== ヘルパー ============================================================== */ function computeGlowFlags(tokens, color, count) { var flags = []; var matched = 0; for (var i = 0; i < tokens.length; i++) { if (color && tokens[i] === color && matched < count) { flags.push(true); matched++; } else { flags.push(false); } } return flags; } function useMeasuredSize() { var elementState = React.useState(null); var element = elementState[0]; var setElement = elementState[1]; var sizeState = React.useState({ width: 0, height: 0 }); var size = sizeState[0]; var setSize = sizeState[1]; var ref = React.useCallback(function (node) { setElement(node); }, []); React.useLayoutEffect(function () { var el = element; if (!el) return; var update = function () { var rect = el.getBoundingClientRect(); setSize(function (prev) { if (Math.abs(prev.width - rect.width) < 0.5 && Math.abs(prev.height - rect.height) < 0.5) { return prev; } return { width: rect.width, height: rect.height }; }); }; update(); if (window.ResizeObserver) { var ro = new ResizeObserver(update); ro.observe(el); return function () { ro.disconnect(); }; } window.addEventListener('resize', update); return function () { window.removeEventListener('resize', update); }; }, [element]); return { ref: ref, size: size }; } function getBoardCameraTarget(camera, containerSize) { var maxNormalHeight = 655; var maxNormalWidth = maxNormalHeight * 1588 / 2167; var maxTokenViewportWidth = 425; var maxTokenBaseWidth = Math.min(maxTokenViewportWidth * 0.85, maxNormalWidth); var boardPerspective = 900; var boardRotateX = 28 * Math.PI / 180; var baseWidthCss = 'min(85cqw, calc(100cqh * 1588 / 2167), calc(655px * 1588 / 2167))'; var baseWidth = 0; var visualWidth = 0; var shiftX = 0; var shiftY = 0; if (containerSize.width > 0 && containerSize.height > 0) { baseWidth = Math.min(containerSize.width * 0.85, containerSize.height * 1588 / 2167, maxNormalWidth); visualWidth = baseWidth; if (camera === 'tokens') { visualWidth = Math.min(baseWidth, maxTokenBaseWidth) * 2.3; var targetHeight = visualWidth * 2167 / 1588; var nearEdgeZ = targetHeight / 2 * Math.sin(boardRotateX); var perspectiveScale = boardPerspective / Math.max(1, boardPerspective - nearEdgeZ); shiftX = (containerSize.width / perspectiveScale - visualWidth) / (2 * visualWidth) * 100; shiftY = containerSize.height / 2 - targetHeight / 2 - 22; } else if (camera === 'center') { visualWidth = baseWidth * 1.5; } return { width: Math.max(1, visualWidth) + 'px', visualWidth: Math.max(1, visualWidth), shiftX: shiftX, shiftY: shiftY, hasSize: true, }; } if (camera === 'tokens') { var fallbackTokenWidth = maxTokenViewportWidth * 0.85 * 2.3; var fallbackTokenHeight = fallbackTokenWidth * 2167 / 1588; var fallbackNearEdgeZ = fallbackTokenHeight / 2 * Math.sin(boardRotateX); var fallbackPerspectiveScale = boardPerspective / Math.max(1, boardPerspective - fallbackNearEdgeZ); shiftX = (maxTokenViewportWidth / fallbackPerspectiveScale - fallbackTokenWidth) / (2 * fallbackTokenWidth) * 100; } return { width: baseWidthCss, visualWidth: camera === 'tokens' ? 2.3 : (camera === 'center' ? 1.5 : 1), shiftX: shiftX, shiftY: shiftY, hasSize: false, }; } function getBoardCameraTransform(visualTarget, layoutTarget) { var scale = visualTarget.hasSize && layoutTarget.visualWidth > 0 ? visualTarget.visualWidth / layoutTarget.visualWidth : visualTarget.visualWidth; return { transform: 'translate3d(0,' + visualTarget.shiftY.toFixed(2) + 'px,0) rotateX(28deg) scale(' + scale.toFixed(4) + ') translateX(' + visualTarget.shiftX + '%)', rasterScale: Math.max(1, scale), }; } function useBoardCameraLayout(camera, containerSize) { var prevCameraRef = React.useRef(camera); var rafRef = React.useRef([]); var animState = React.useState({ from: camera, to: camera, phase: 'idle' }); var anim = animState[0]; var setAnim = animState[1]; React.useLayoutEffect(function () { rafRef.current.forEach(function (id) { cancelAnimationFrame(id); }); rafRef.current = []; if (containerSize.width <= 0 || containerSize.height <= 0) { prevCameraRef.current = camera; setAnim({ from: camera, to: camera, phase: 'idle' }); return; } var prevCamera = prevCameraRef.current; if (prevCamera === camera) return; prevCameraRef.current = camera; setAnim({ from: prevCamera, to: camera, phase: 'start' }); var raf1 = requestAnimationFrame(function () { var raf2 = requestAnimationFrame(function () { setAnim({ from: prevCamera, to: camera, phase: 'run' }); }); rafRef.current.push(raf2); }); rafRef.current.push(raf1); return function () { rafRef.current.forEach(function (id) { cancelAnimationFrame(id); }); rafRef.current = []; }; }, [camera, containerSize.width, containerSize.height]); var layoutCamera = anim.phase === 'idle' ? camera : anim.to; var visualCamera = anim.phase === 'start' ? anim.from : layoutCamera; var layoutTarget = getBoardCameraTarget(layoutCamera, containerSize); var visualTarget = getBoardCameraTarget(visualCamera, containerSize); var tx = getBoardCameraTransform(visualTarget, layoutTarget); return { width: layoutTarget.width, transform: tx.transform, rasterScale: tx.rasterScale, transition: anim.phase === 'start' ? 'none' : 'transform 0.8s cubic-bezier(.22,.61,.36,1)', }; } /* ============================================================== キャラクター画面(シーン 1〜6) ============================================================== */ function CharacterScreen({ character }) { var visible = character.visible; var focus = character.focus; var imageState = character.imageState !== undefined ? character.imageState : 'normal'; var scale = character.scale !== undefined ? character.scale : 1; var shiftX = character.shiftX !== undefined ? character.shiftX : 0; var shiftY = character.shiftY !== undefined ? character.shiftY : 0; var spotX = character.spotX !== undefined ? character.spotX : 50; var spotY = character.spotY !== undefined ? character.spotY : 50; var spotSize = character.spotSize !== undefined ? character.spotSize : 28; var spotSoftness = character.spotSoftness !== undefined ? character.spotSoftness : 8; var wrapStyle = { flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', background: '#05050c', display: 'flex', alignItems: 'center', justifyContent: 'center', }; if (!visible) { var darkStyle = Object.assign({}, wrapStyle, { background: '#000', }); return React.createElement('div', { style: darkStyle }); } var imgStyle = { position: 'absolute', width: '100%', height: '100%', objectFit: 'cover', objectPosition: '50% 0%', transform: 'scale(' + scale + ') translateX(' + shiftX + '%) translateY(' + shiftY + '%)', opacity: 1, }; // spotSoftness が負の場合はハードエッジとして扱う var innerStop = Math.max(0, spotSize); var outerStop = Math.max(innerStop + 0.1, spotSize + spotSoftness); // 'full' モード時のターゲット値(スポットが広がって全体が見える) var isMouth = focus === 'mouth'; var tSpotX = isMouth ? spotX : 50; var tSpotY = isMouth ? spotY : 60; var tInner = isMouth ? innerStop : 55; var tOuter = isMouth ? outerStop : 100; var tAlpha = isMouth ? 1 : 0.75; // @property で定義したカスタムプロパティを使うことで gradient がアニメーション可能になる var overlayStyle = { position: 'absolute', inset: 0, pointerEvents: 'none', transition: '--dt-spot-size 0.8s ease, --dt-outer-stop 0.8s ease, --dt-spot-x 0.6s ease, --dt-spot-y 0.6s ease, --dt-spot-alpha 0.8s ease', '--dt-spot-size': tInner + '%', '--dt-outer-stop': tOuter + '%', '--dt-spot-x': tSpotX + '%', '--dt-spot-y': tSpotY + '%', '--dt-spot-alpha': tAlpha, background: 'radial-gradient(circle at var(--dt-spot-x) var(--dt-spot-y), transparent var(--dt-spot-size), rgba(5,5,12,var(--dt-spot-alpha)) var(--dt-outer-stop))', }; return React.createElement('div', { style: wrapStyle }, React.createElement('div', { key: 'rue-' + imageState, style: { position: 'absolute', inset: 0, animation: imageState === 'whisp' ? 'opRueWhispIn 0.6s cubic-bezier(0.2,0,0.2,1)' : 'opRueFadeIn 0.6s ease' }, }, React.createElement('img', { src: 'assets/op_rue_' + imageState + '.webp', alt: '', style: imgStyle }), ), React.createElement('div', { style: overlayStyle }), ); } /* ============================================================== テーゼトークン ============================================================== */ function Token({ face, glowing, renderScale }) { var dispState = React.useState(face); var displayFace = dispState[0]; var setDisplayFace = dispState[1]; var flipState = React.useState(false); var flipping = flipState[0]; var setFlipping = flipState[1]; var isFirst = React.useRef(true); React.useEffect(function() { if (isFirst.current) { isFirst.current = false; return; } setFlipping(true); var mid = setTimeout(function() { setDisplayFace(face); }, 270); var end = setTimeout(function() { setFlipping(false); }, 600); return function() { clearTimeout(mid); clearTimeout(end); }; }, [face]); var src = displayFace === 'white' ? 'uploads/token_white.png' : 'uploads/token_black.png'; var rasterScale = Math.max(1, renderScale || 1); var wrapStyle = { width: '100%', height: '100%', display: 'block', overflow: 'visible', transformOrigin: '50% 50%', }; var s = { width: (100 * rasterScale) + '%', height: (100 * rasterScale) + '%', objectFit: 'contain', display: 'block', transform: 'scale(' + (1 / rasterScale) + ')', transformOrigin: '0 0', filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.7))', }; if (glowing) s.animation = 'tokenGlowPulse 1.5s ease-in-out infinite'; else if (flipping) wrapStyle.animation = 'tokenFlip 0.6s ease-in-out'; return React.createElement('span', { style: wrapStyle }, React.createElement('img', { src: src, style: s }) ); } /* ============================================================== 移動中トークン(魔力へのフライアニメーション) ============================================================== */ function MovingToken(props) { var st = React.useState(false); var moved = st[0]; var setMoved = st[1]; var ms = React.useState(false); var moving = ms[0]; var setMoving = ms[1]; React.useEffect(function () { var id1 = requestAnimationFrame(function () { requestAnimationFrame(function () { setMoved(true); setMoving(true); }); }); return function () { cancelAnimationFrame(id1); }; }, []); React.useEffect(function () { if (!moving) return; var tid = setTimeout(function () { setMoving(false); }, 1000); return function () { clearTimeout(tid); }; }, [moving]); var left = (moved ? props.endLeft : props.startLeft) + '%'; var top = (moved ? props.endTop : props.startTop) + '%'; var rasterScale = Math.max(1, props.renderScale || 1); return React.createElement('div', { style: { position: 'absolute', left: left, top: top, width: TOKEN_W + '%', height: TOKEN_H + '%', zIndex: 15, pointerEvents: 'none', transition: 'left 1.0s cubic-bezier(0.4,0,0.2,1), top 1.0s cubic-bezier(0.4,0,0.2,1)', transform: props.rotated ? 'rotate(180deg)' : undefined, }, }, React.createElement('span', { style: { width: '100%', height: '100%', display: 'block', overflow: 'visible', animation: moving ? 'tokenFlip 0.3s ease-in-out infinite' : 'none', transformOrigin: '50% 50%', }, }, React.createElement('img', { src: 'uploads/token_black.png', style: { width: (100 * rasterScale) + '%', height: (100 * rasterScale) + '%', objectFit: 'contain', display: 'block', transform: 'scale(' + (1 / rasterScale) + ')', transformOrigin: '0 0', filter: 'drop-shadow(0 0 6px rgba(167,139,250,0.9)) drop-shadow(0 0 12px rgba(124,58,237,0.7))', }, }) ), ); } /* ============================================================== ボード上のカード ============================================================== */ function BoardCard({ name }) { var imgSrc = CARD_IMAGES[name] || null; var s = { height: '100%', width: 'auto', display: 'block', borderRadius: 3, border: '1px solid rgba(130,120,180,0.3)', flexShrink: 0, }; if (imgSrc) { return React.createElement('img', { src: imgSrc, alt: name, style: s }); } // 画像なし → 標準的なカード縦横比(5:7)でテキスト表示 return React.createElement('div', { style: Object.assign({}, s, { aspectRatio: '5 / 7', background: 'linear-gradient(180deg,rgba(22,22,40,.92),rgba(14,14,28,.95))', display: 'flex', alignItems: 'center', justifyContent: 'center', }) }, React.createElement('span', { style: { color: '#d0d0e0', fontSize: 9, fontWeight: 500, textAlign: 'center', lineHeight: 1.2, fontFamily: "'Shippori Mincho',serif", padding: '0 2px', } }, name), ); } /* ============================================================== フローティングカード(選択肢演出) ============================================================== */ // cardWidth: CSS width 値('30cqw' などの文字列)。高さは画像のアスペクト比に自動追従。 function FloatCard({ name, glowing, cardWidth, costImg }) { var imgSrc = CARD_IMAGES[name] || null; var w = cardWidth || '30cqw'; var s = { width: w, height: 'auto', display: 'block', borderRadius: 5, border: '1px solid rgba(140,130,180,0.3)', animation: 'cardSlideIn 0.5s ease-out', transition: 'all 0.4s ease', flexShrink: 0, }; if (imgSrc) { var shadowFilter = 'drop-shadow(0 0 8px rgba(124,58,237,.7)) drop-shadow(0 0 18px rgba(124,58,237,.4))'; var rawImageStyle = { width: '100%', height: 'auto', display: 'block', }; return React.createElement('span', { style: { position: 'relative', display: 'block', width: w, height: 'auto', lineHeight: 0, animation: 'cardSlideIn 0.5s ease-out', transition: 'all 0.4s ease', flexShrink: 0, }, }, glowing && React.createElement('img', { src: imgSrc, alt: '', 'aria-hidden': true, style: { position: 'absolute', top: 0, left: 0, width: rawImageStyle.width, height: rawImageStyle.height, display: rawImageStyle.display, filter: shadowFilter, pointerEvents: 'none', }, }), React.createElement('img', { src: imgSrc, alt: name, style: Object.assign({}, rawImageStyle, { position: 'relative', zIndex: 1, }), }), costImg && React.createElement('img', { src: costImg, alt: '', 'aria-hidden': true, style: { position: 'absolute', top: 0, left: 0, width: '100%', height: 'auto', display: 'block', zIndex: 2, pointerEvents: 'none', animation: 'costGlowPulse 1.5s ease-in-out infinite', }, }), ); } if (glowing) { s.borderColor = 'rgba(124,58,237,.6)'; } // 画像なし → 5:7 縦横比でテキスト表示 return React.createElement('div', { style: Object.assign({}, s, { aspectRatio: '5 / 7', background: 'linear-gradient(180deg,#1a1a2c,#121220)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 4, boxShadow: glowing ? '0 0 12px 4px rgba(124,58,237,.5)' : undefined, }) }, React.createElement('span', { style: { color: '#d0d0e0', fontSize: 11, fontWeight: 500, textAlign: 'center', lineHeight: 1.3, fontFamily: "'Shippori Mincho',serif", } }, name), ); } function FloatingCardOverlay({ card, dimmed, focusCost }) { if (!card) return null; // tilt: floatCards 由来(右10°傾き・大きめ表示) // flipped: floatCard 由来(葛藤で180°回転) var isTilt = !!card.tilt; var flipped = card.flipped; var baseRot = isTilt ? 10 : 0; var rotation = flipped ? baseRot + 180 : baseRot; var wrap = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 20, pointerEvents: 'none', filter: dimmed ? 'brightness(0.2)' : 'none', transition: 'filter 0.5s ease', }; var cardWrap = isTilt ? { position: 'absolute', top: '50%', left: '43%', transform: 'translate(-50%, -50%)', } : { position: 'absolute', top: '8%', left: '3%', }; return React.createElement('div', { style: wrap }, React.createElement('div', { style: cardWrap }, React.createElement('div', { style: { animation: 'floatIn 0.6s ease-out' } }, React.createElement('div', { style: { transform: 'rotate(' + rotation + 'deg)', transition: 'transform 0.8s ease-in-out', } }, React.createElement(FloatCard, { name: card.name, cardWidth: isTilt ? '65cqw' : '52cqw', glowing: true, costImg: focusCost ? ('assets/card_' + card.name + '_cost.webp') : undefined, }), ), ), ), ); } /* ============================================================== メインボードコンポーネント ============================================================== */ function Board({ state }) { var measured = useMeasuredSize(); var boardWrapRef = measured.ref; var boardWrapSize = measured.size; var screen = state.screen; var character = state.character; var boardVisible = state.boardVisible; var camera = state.camera; var whiteOut = state.whiteOut; var opponent = state.opponent; var player = state.player; var floatingCard = state.floatingCard; var glows = state.glows; var resolvedCards= state.resolvedCards; var fadeOut = state.fadeOut; var tokenMotion = state.tokenMotion; var dreamTokens = state.dreamTokens || { player: [], opponent: [] }; var dreamMotion = state.dreamMotion || null; var momentCard = state.momentCard || null; var offering = state.offering || false; var boardImage = state.boardImage || 'uploads/board_default.jpg'; var cameraLayout = useBoardCameraLayout(camera, boardWrapSize); var tokenRenderScale = cameraLayout.rasterScale; // ---- キャラクター画面(シーン 1〜6)---- if (screen === 'dark' || screen === 'character') { return React.createElement(CharacterScreen, { character: character }); } // ---- ボード画面(シーン 7〜)---- if (!boardVisible) { return React.createElement('div', { style: { flex: 1, minHeight: 0, background: '#08080f' } }); } var glowW = 0, glowB = 0, glowP = 0, glowPlayerTokenArea = false, glowFocusCardCost = false; if (glows) { (glows.targets || []).forEach(function (t) { if (t.indexOf('whiteTokens:') === 0) glowW = parseInt(t.split(':')[1]); if (t.indexOf('blackTokens:') === 0) glowB = parseInt(t.split(':')[1]); if (t.indexOf('playerTokens:') === 0) glowP = parseInt(t.split(':')[1]); if (t === 'playerTokenArea') glowPlayerTokenArea = true; if (t === 'focusCardcost1' || t === 'focusCardcost2') glowFocusCardCost = true; }); } var kids = []; if (boardImage !== 'uploads/board_default.jpg') { kids.push(React.createElement('div', { key: 'board-overlay', style: { position: 'absolute', inset: 0, zIndex: 0, backgroundImage: 'url(' + boardImage + ')', backgroundSize: '100% 100%', backgroundRepeat: 'no-repeat', borderRadius: 2, animation: 'boardImageSwap 0.4s ease forwards', }, })); } // 自分トークン if (player.tokens.length > 0) { var pFlags; if (tokenMotion && tokenMotion.side === 'player') { pFlags = player.tokens.map(function (_, i) { return tokenMotion.indices.indexOf(i) >= 0; }); } else if (glowP > 0) { pFlags = player.tokens.map(function (_, i) { return i < glowP; }); } else { var pColor = glowW > 0 ? 'white' : (glowB > 0 ? 'black' : null); pFlags = computeGlowFlags(player.tokens, pColor, glowW || glowB); } player.tokens.forEach(function (face, i) { if (!face) return; // null = 魔力に送られたトークン var pos = PLAYER_POS[i]; if (!pos) return; kids.push(React.createElement('div', { key: 'pt' + i, style: { position: 'absolute', left: pos[0] + '%', top: pos[1] + '%', width: TOKEN_W + '%', height: TOKEN_H + '%', transform: pos[2] ? 'rotate(180deg)' : undefined, zIndex: glowPlayerTokenArea ? 7 : 3, }}, React.createElement(Token, { face: face, glowing: pFlags[i], renderScale: tokenRenderScale }))); }); } // 相手トークン if (opponent.tokens.length > 0) { var oFlags; if (tokenMotion && tokenMotion.side === 'opponent') { oFlags = opponent.tokens.map(function (_, i) { return tokenMotion.indices.indexOf(i) >= 0; }); } else { var oColor = glowB > 0 ? 'black' : null; oFlags = computeGlowFlags(opponent.tokens, oColor, glowB); } opponent.tokens.forEach(function (face, i) { if (!face) return; // null = 魔力に送られたトークン var pos = OPPONENT_POS[i]; if (!pos) return; kids.push(React.createElement('div', { key: 'ot' + i, style: { position: 'absolute', left: pos[0] + '%', top: pos[1] + '%', width: TOKEN_W + '%', height: TOKEN_H + '%', transform: pos[2] ? 'rotate(180deg)' : undefined, zIndex: 3, transition: 'all 0.3s ease', }}, React.createElement(Token, { face: face, glowing: oFlags[i], renderScale: tokenRenderScale }))); }); } // 中央クラスター(initTokens 後に表示、常に黒・全て rotate(180deg)) if (player.tokens.length > 0) { ALTER_OPPONENT_POS.forEach(function (pos, i) { kids.push(React.createElement('div', { key: 'ao' + i, style: { position: 'absolute', left: pos[0] + '%', top: pos[1] + '%', width: TOKEN_W + '%', height: TOKEN_H + '%', zIndex: 3, }}, React.createElement(Token, { face: 'black', glowing: false, renderScale: tokenRenderScale }))); }); ALTER_PLAYER_POS.forEach(function (pos, i) { var p = pos; if (offering) { if (i === 0) p = [46.4, 82.9]; if (i === 4) p = [51.5, 82.9]; } kids.push(React.createElement('div', { key: 'ap' + i, style: { position: 'absolute', left: p[0] + '%', top: p[1] + '%', width: TOKEN_W + '%', height: TOKEN_H + '%', transform: 'rotate(180deg)', zIndex: 3, transition: 'left 0.6s ease, top 0.6s ease', }}, React.createElement(Token, { face: 'black', glowing: false, renderScale: tokenRenderScale }))); }); // cardback(offering 後: rotate(-90deg) で alter 右に配置) if (offering) { kids.push(React.createElement('div', { key: 'offeringCard', style: { position: 'absolute', left: '57%', top: '84.5%', height: '16.8%', zIndex: 3, animation: 'offeringCardSlideIn 0.55s cubic-bezier(0.15,0,0,1) forwards', } }, React.createElement('img', { src: 'assets/cardback.webp', alt: '', style: { height: '100%', width: 'auto', display: 'block', borderRadius: 3, border: '1px solid rgba(130,120,180,0.3)' }, }) )); } } // 相手カード var CARD_ANIM = 'cardArriveFlash 0.85s ease-out 0.35s both'; opponent.cards.forEach(function (card, i) { var sl = SLOTS.opponent[i]; if (!sl) return; var res = resolvedCards && (resolvedCards['opponent:' + card.name] || resolvedCards[card.name]); var oAnim = res ? (res === 'non-attack' ? 'resolveCardNonAttack 0.6s ease' : ('resolveCardOpponent' + (res === 'hit' ? 'Hit' : 'Blocked') + ' 0.7s forwards')) : 'cardSlideInOpponent 0.55s cubic-bezier(0.15,0,0,1), ' + CARD_ANIM; kids.push(React.createElement('div', { key: 'o' + i, style: { position: 'absolute', left: sl.left + '%', top: sl.top + '%', height: sl.h + '%', animation: oAnim, zIndex: 2, }}, React.createElement('div', { style: { height: '100%', transform: card.flipped ? 'none' : 'rotate(180deg)', }}, React.createElement(BoardCard, { name: card.name }), ), )); }); // 自分カード player.cards.forEach(function (card, i) { var sl = SLOTS.player[i]; if (!sl) return; var res = resolvedCards && (resolvedCards['player:' + card.name] || resolvedCards[card.name]); var pAnim = res ? (res === 'non-attack' ? 'resolveCardNonAttack 0.6s ease' : ('resolveCardPlayer' + (res === 'hit' ? 'Hit' : 'Blocked') + ' 0.7s forwards')) : 'cardSlideInPlayer 0.55s cubic-bezier(0.15,0,0,1), ' + CARD_ANIM; kids.push(React.createElement('div', { key: 'p' + i, style: { position: 'absolute', left: sl.left + '%', top: sl.top + '%', height: sl.h + '%', animation: pAnim, zIndex: 2, }}, React.createElement('div', { style: { height: '100%', transform: card.flipped ? 'rotate(180deg)' : undefined, }}, React.createElement(BoardCard, { name: card.name }), ), )); }); // モーメントカード(相手スロット左・3D フリップ) if (momentCard) { var mFaceUp = momentCard.faceUp !== false; var mCardStyle = { height: '100%', width: 'auto', display: 'block', borderRadius: 3, border: '1px solid rgba(130,120,180,0.3)' }; var mFallStyle = { height: '100%', aspectRatio: '5 / 7', background: 'linear-gradient(180deg,rgba(22,22,40,.92),rgba(14,14,28,.95))', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 3, border: '1px solid rgba(130,120,180,0.3)' }; var mLblStyle = { color: '#d0d0e0', fontSize: 9, fontWeight: 500, textAlign: 'center', lineHeight: 1.2, fontFamily: "'Shippori Mincho',serif", padding: '0 2px' }; var mImgStyle = { height: '100%', width: 'auto', display: 'block', borderRadius: 3, border: '1px solid rgba(130,120,180,0.3)' }; var mFace = function (name, abs) { var src = CARD_IMAGES[name]; var base = { height: '100%', backfaceVisibility: 'hidden', WebkitBackfaceVisibility: 'hidden' }; if (abs) { base.position = 'absolute'; base.top = 0; base.left = 0; base.transform = 'rotateY(180deg)'; } return React.createElement('div', { style: base }, src ? React.createElement('img', { src: src, alt: name, style: mImgStyle }) : React.createElement('div', { style: mFallStyle }, React.createElement('span', { style: mLblStyle }, name)) ); }; kids.push(React.createElement('div', { key: 'momentCard', style: { position: 'absolute', left: '1.5%', top: '28%', height: '16.8%', zIndex: 2, perspective: '600px', transform: 'rotate(180deg)' }, }, React.createElement('div', { style: { height: '100%', transformStyle: 'preserve-3d', transition: 'transform 0.7s ease', transform: mFaceUp ? 'rotateY(0deg)' : 'rotateY(180deg)' }, }, mFace('moment_a', false), mFace('moment_b', true) ) )); } // 魔力トークン(zone slot 位置に独立配置、tokenMotion中は非表示) // guardian X: 相手側スロットのカード中心 / reinforcement X: 自分側スロットのカード中心 ['opponent', 'player'].forEach(function (side) { var sideCards = side === 'player' ? player.cards : opponent.cards; var sideMagic = side === 'player' ? player : opponent; var otherSide = side === 'player' ? 'opponent' : 'player'; sideCards.forEach(function (card, i) { var sl = SLOTS[side][i]; var otherSl = SLOTS[otherSide][i]; var guardianCx = otherSl ? cardCenterX(otherSl) : (sl ? cardCenterX(sl) : 0); var reinforceCx = sl ? cardCenterX(sl) : 0; var gPos = GUARDIAN_SLOTS[side][i]; var gInMotion = tokenMotion && tokenMotion.side === side && tokenMotion.zone === 'guardian' && tokenMotion.targetSlot === i; var gC = gInMotion ? 0 : ((sideMagic.guardianTokens && sideMagic.guardianTokens[card.name]) || 0); var magicRot = side === 'opponent' ? 'rotate(180deg)' : undefined; var reinforcementRot = side === 'player' ? 'rotate(180deg)' : undefined; if (gC > 0 && gPos && otherSl) { for (var gi = 0; gi < gC; gi++) { var gOff = (gi - (gC - 1) / 2) * (TOKEN_W + 0.5); kids.push(React.createElement('div', { key: side + 'gm' + i + '_' + gi, style: { position: 'absolute', left: (guardianCx - TOKEN_W / 2 + gOff) + '%', top: gPos.top + '%', width: TOKEN_W + '%', height: TOKEN_H + '%', transform: magicRot, zIndex: 4, pointerEvents: 'none', }, }, React.createElement(Token, { face: 'black', glowing: false, renderScale: tokenRenderScale }))); } } var rPos = REINFORCEMENT_SLOTS[side][i]; var rInMotion = tokenMotion && tokenMotion.side === side && tokenMotion.zone === 'reinforcement' && tokenMotion.targetSlot === i; var rC = rInMotion ? 0 : ((sideMagic.reinforcementTokens && sideMagic.reinforcementTokens[card.name]) || 0); if (rC > 0 && rPos && sl) { for (var ri = 0; ri < rC; ri++) { var rOff = (ri - (rC - 1) / 2) * (TOKEN_W + 0.5); kids.push(React.createElement('div', { key: side + 'rm' + i + '_' + ri, style: { position: 'absolute', left: (reinforceCx - TOKEN_W / 2 + rOff) + '%', top: rPos.top + '%', width: TOKEN_W + '%', height: TOKEN_H + '%', transform: reinforcementRot, zIndex: 4, pointerEvents: 'none', }, }, React.createElement(Token, { face: 'black', glowing: false, renderScale: tokenRenderScale }))); } } }); }); if (!dreamMotion) { ['opponent', 'player'].forEach(function (side) { var dtArr = side === 'player' ? dreamTokens.player : dreamTokens.opponent; if (dtArr.length <= 0) return; for (var di = 0; di < dtArr.length; di++) { var dp = dreamPos(di, side); kids.push(React.createElement('div', { key: side + 'dt' + di, style: { position: 'absolute', left: dp.left + '%', top: dp.top + '%', width: TOKEN_W + '%', height: TOKEN_H + '%', transform: dp.rot, zIndex: 4, pointerEvents: 'none', }, }, React.createElement(Token, { face: dtArr[di], glowing: false, renderScale: tokenRenderScale }))); } }); } // 移動中トークン(黒トークンがカードスロットへフライ) if (tokenMotion && tokenMotion.indices && tokenMotion.indices.length > 0) { var motSrcPos = tokenMotion.side === 'player' ? PLAYER_POS : OPPONENT_POS; var zoneSlots = tokenMotion.zone === 'guardian' ? GUARDIAN_SLOTS : REINFORCEMENT_SLOTS; var zonePos = zoneSlots[tokenMotion.side] && zoneSlots[tokenMotion.side][tokenMotion.targetSlot]; // guardian: 相手側スロットのX / reinforcement: 自分側スロットのX var motXSide = tokenMotion.zone === 'guardian' ? (tokenMotion.side === 'player' ? 'opponent' : 'player') : tokenMotion.side; var motSl = SLOTS[motXSide] && SLOTS[motXSide][tokenMotion.targetSlot]; if (zonePos && motSl) { var motEndLeft = cardCenterX(motSl) - TOKEN_W / 2; var motN = tokenMotion.indices.length; tokenMotion.indices.forEach(function (idx, k) { var sp = motSrcPos[idx]; if (!sp) return; var endOffset = (k - (motN - 1) / 2) * (TOKEN_W + 0.5); kids.push(React.createElement(MovingToken, { key: 'mv' + tokenMotion.key + ':' + idx, startLeft: sp[0], startTop: sp[1], endLeft: motEndLeft + endOffset, endTop: zonePos.top, rotated: tokenMotion.zone === 'reinforcement' ? tokenMotion.side === 'player' : tokenMotion.side === 'opponent', renderScale: tokenRenderScale, })); }); } } if (dreamMotion) { ['opponent', 'player'].forEach(function (side) { var dm = dreamMotion[side]; if (!dm) return; var otherSide = side === 'player' ? 'opponent' : 'player'; var allSrc = []; dm.guardian.forEach(function (entry) { var sl = SLOTS[side][entry.slotIndex]; var otherSl = SLOTS[otherSide][entry.slotIndex]; var gCx = otherSl ? cardCenterX(otherSl) : (sl ? cardCenterX(sl) : 0); var gTop = GUARDIAN_SLOTS[side][entry.slotIndex] ? GUARDIAN_SLOTS[side][entry.slotIndex].top : 50; for (var gi = 0; gi < entry.count; gi++) { var gOff = (gi - (entry.count - 1) / 2) * (TOKEN_W + 0.5); allSrc.push({ startLeft: gCx - TOKEN_W / 2 + gOff, startTop: gTop }); } }); dm.reinforcement.forEach(function (entry) { var sl = SLOTS[side][entry.slotIndex]; var rCx = sl ? cardCenterX(sl) : 0; var rTop = REINFORCEMENT_SLOTS[side][entry.slotIndex] ? REINFORCEMENT_SLOTS[side][entry.slotIndex].top : 50; for (var ri = 0; ri < entry.count; ri++) { var rOff = (ri - (entry.count - 1) / 2) * (TOKEN_W + 0.5); allSrc.push({ startLeft: rCx - TOKEN_W / 2 + rOff, startTop: rTop }); } }); var total = allSrc.length; var priorCount = (side === 'player' ? dreamTokens.player.length : dreamTokens.opponent.length) - total; allSrc.forEach(function (src, idx) { var dp = dreamPos(priorCount + idx, side); kids.push(React.createElement(MovingToken, { key: 'dmt' + dreamMotion.key + ':' + side + ':' + idx, startLeft: src.startLeft, startTop: src.startTop, endLeft: dp.left, endTop: dp.top, rotated: dp.rot === 'rotate(180deg)', renderScale: tokenRenderScale, })); }); }); } // playerTokenArea グロー:ボード全体を暗くし、aware.webp とプレイヤートークンだけを浮かび上がらせる if (glowPlayerTokenArea) { kids.push(React.createElement('div', { key: 'pta-dim', style: { position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.65)', zIndex: 5, pointerEvents: 'none', animation: 'dimFadeIn 0.5s ease', }})); kids.push(React.createElement('img', { key: 'pta-aware', src: 'assets/aware.webp', alt: '', style: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'fill', zIndex: 6, pointerEvents: 'none', filter: 'drop-shadow(0 0 5px rgba(167,139,250,0.9)) drop-shadow(0 0 14px rgba(124,58,237,0.8))', }})); } return React.createElement('div', { ref: boardWrapRef, style: { flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', perspective: 900, perspectiveOrigin: '50% 55%', position: 'relative', overflow: 'hidden', containerType: 'size', opacity: fadeOut ? 0 : 1, transition: 'opacity 2s ease', } }, React.createElement('div', { style: { width: cameraLayout.width, flexShrink: 0, aspectRatio: '1588 / 2167', backgroundImage: 'url(uploads/board_default.jpg)', backgroundSize: '100% 100%', backgroundRepeat: 'no-repeat', borderRadius: 2, boxShadow: '0 8px 40px rgba(0,0,0,.6), 0 0 60px rgba(60,30,100,.06)', transform: cameraLayout.transform, transition: cameraLayout.transition, willChange: 'transform', position: 'relative', WebkitBackfaceVisibility: 'hidden', backfaceVisibility: 'hidden', transformOrigin: '50% 50%', } }, kids), React.createElement(FloatingCardOverlay, { card: floatingCard, dimmed: glowPlayerTokenArea, focusCost: glowFocusCardCost }), ); } window.Board = Board; window.CARD_IMAGES = CARD_IMAGES;