chore: 初始化仓库,提交 vibevideo 源码
- src/: HyperFrames 封装 CLI(hv)、组件、品牌样式、音频(TTS/混音)工具 - projects/: 各项目 project.yaml 与文案 - interactive/: 井字棋演示页 - .gitignore: 忽略 node_modules、构建产物与 HyperFrames 运行期缓存
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
井字棋 —— 交互网页可行性验证
|
||||
--------------------------------------------------------------------------
|
||||
目的不是做游戏,是验证一件事:**这套框架的东西能不能拿来写交互网页**。
|
||||
|
||||
答案分三块,这个页面分别用了其中一块:
|
||||
|
||||
1. 设计规范直接复用(零拷贝)
|
||||
tokens.css 的色板、字体回退链、--u 基准单位全部原样加载,
|
||||
只有两处针对网页做了覆盖,见下方 <style> 里的注释。
|
||||
|
||||
2. 渲染逻辑和视频组件是同一个套路
|
||||
视频组件里是 render(t) —— 纯函数:时间 t → 画面
|
||||
这个页面里是 render(s) —— 纯函数:状态 s → DOM
|
||||
唯一区别是「s 从哪来」。视频里 t 由时间轴给,网页里 s 由点击给。
|
||||
|
||||
3. 没有时间轴,没有渲染引擎
|
||||
没有 data-start / data-duration / window.__timelines,
|
||||
不需要 hyperframes,也不需要 ffmpeg。就是一个普通网页。
|
||||
|
||||
怎么打开:见同目录 README,或在项目根目录起个静态服务器
|
||||
python3 -m http.server 8000
|
||||
然后访问 http://localhost:8000/interactive/tictactoe/
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>井字棋 · 交互网页验证</title>
|
||||
|
||||
<!-- ① 直接吃框架的设计规范。注意是相对路径,没复制任何文件。 -->
|
||||
<link rel="stylesheet" href="../../src/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="../../src/brand/base.css" />
|
||||
|
||||
<style>
|
||||
/* ---- 网页侧要覆盖的地方,只有这两类 --------------------------
|
||||
其余所有 calc(N * var(--u)) 的写法都原样有效。 */
|
||||
|
||||
/* (a) 基准单位:视频里是固定的 1920x1080 舞台,网页里要跟视口走。
|
||||
tokens.css 原本的公式是「短边 / 1080 px」,所以 1080p 下 --u 正好是 1px。
|
||||
网页没有固定舞台,用 vmin 复刻同一个比例:
|
||||
100vmin = 视口短边的像素数,除以 1080 → 和视频里完全一致的缩放行为
|
||||
换算保持不变,calc(40 * var(--u)) 之类的写法一行都不用改。 */
|
||||
:root {
|
||||
--u: calc(100vmin / 1080);
|
||||
}
|
||||
|
||||
/* (b) base.css 把 body 写死成 --stage-w/h 像素 + overflow:hidden,
|
||||
那是视频舞台的要求(不能滚动、不能有滚动条出现在成片里)。
|
||||
网页要反过来:撑满视口,允许内容超出。 */
|
||||
html,
|
||||
body {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: calc(40 * var(--u));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- 布局 ---------------------------------------------------- */
|
||||
.wrap {
|
||||
width: 100%;
|
||||
max-width: calc(760 * var(--u));
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: calc(13 * var(--u));
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: calc(10 * var(--u));
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(40 * var(--u));
|
||||
margin-bottom: calc(8 * var(--u));
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: calc(16 * var(--u));
|
||||
color: var(--text-dim);
|
||||
margin-bottom: calc(28 * var(--u));
|
||||
}
|
||||
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: calc(20 * var(--u));
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.cols {
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 棋盘 ---------------------------------------------------- */
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: calc(8 * var(--u));
|
||||
width: calc(320 * var(--u));
|
||||
padding: calc(8 * var(--u));
|
||||
background: var(--surface);
|
||||
border: calc(1 * var(--u)) solid var(--border);
|
||||
border-radius: calc(18 * var(--u));
|
||||
box-shadow: 0 calc(24 * var(--u)) calc(64 * var(--u)) rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.cell {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(52 * var(--u));
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
background: var(--surface-2);
|
||||
border: calc(1 * var(--u)) solid var(--border);
|
||||
border-radius: calc(12 * var(--u));
|
||||
color: var(--text-ghost);
|
||||
cursor: pointer;
|
||||
/* 点击的手感。这是网页才有的东西,视频里不需要。 */
|
||||
transition: background var(--d-fast), border-color var(--d-fast),
|
||||
transform var(--d-fast);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cell:hover:not(:disabled) {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(calc(-1 * var(--u)));
|
||||
}
|
||||
|
||||
.cell:active:not(:disabled) {
|
||||
transform: translateY(0) scale(0.97);
|
||||
}
|
||||
|
||||
.cell:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 两个棋子用 tokens.css 里现成的语义色,跟视频组件一个色系。 */
|
||||
.cell[data-mark="X"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
.cell[data-mark="O"] {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
/* 赢的那三个格子高亮 */
|
||||
.cell.win {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--surface-2));
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- 侧栏 ---------------------------------------------------- */
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(14 * var(--u));
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: calc(22 * var(--u));
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(10 * var(--u));
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: calc(12 * var(--u));
|
||||
height: calc(12 * var(--u));
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
.dot[data-turn="O"] {
|
||||
background: var(--info);
|
||||
}
|
||||
.dot[data-turn="-"] {
|
||||
background: var(--text-ghost);
|
||||
}
|
||||
|
||||
.moves {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(13 * var(--u));
|
||||
color: var(--text-faint);
|
||||
line-height: 1.7;
|
||||
word-break: break-all;
|
||||
min-height: calc(52 * var(--u));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: calc(10 * var(--u));
|
||||
}
|
||||
|
||||
button.act {
|
||||
font-family: inherit;
|
||||
font-size: calc(15 * var(--u));
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
border: calc(1 * var(--u)) solid var(--border-strong);
|
||||
border-radius: calc(10 * var(--u));
|
||||
padding: calc(9 * var(--u)) calc(18 * var(--u));
|
||||
cursor: pointer;
|
||||
transition: background var(--d-fast), border-color var(--d-fast);
|
||||
}
|
||||
|
||||
button.act:hover:not(:disabled) {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
button.act:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- 这段说明是给人看的,不是装饰 --------------------------- */
|
||||
.note {
|
||||
margin-top: calc(26 * var(--u));
|
||||
padding: calc(14 * var(--u)) calc(16 * var(--u));
|
||||
border-left: calc(3 * var(--u)) solid var(--accent-dim);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 0 calc(10 * var(--u)) calc(10 * var(--u)) 0;
|
||||
font-size: calc(13 * var(--u));
|
||||
line-height: 1.75;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.note b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.note code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(12 * var(--u));
|
||||
background: var(--surface-2);
|
||||
padding: calc(1 * var(--u)) calc(5 * var(--u));
|
||||
border-radius: calc(4 * var(--u));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(11 * var(--u));
|
||||
background: var(--surface-2);
|
||||
border: calc(1 * var(--u)) solid var(--border-strong);
|
||||
border-bottom-width: calc(2 * var(--u));
|
||||
border-radius: calc(4 * var(--u));
|
||||
padding: calc(1 * var(--u)) calc(5 * var(--u));
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="eyebrow">交互网页验证</div>
|
||||
<h1 class="display">井字棋</h1>
|
||||
<p class="lede">
|
||||
点格子落子。赢家三个格子会高亮。<kbd>1</kbd>–<kbd>9</kbd>
|
||||
也可以键盘落子。
|
||||
</p>
|
||||
|
||||
<div class="cols">
|
||||
<!-- 棋盘 -->
|
||||
<div class="board" id="board" role="grid" aria-label="井字棋棋盘"></div>
|
||||
|
||||
<!-- 侧栏 -->
|
||||
<div class="side">
|
||||
<div class="status">
|
||||
<span class="dot" id="dot" data-turn="X"></span>
|
||||
<span id="status">轮到 X</span>
|
||||
</div>
|
||||
|
||||
<div class="moves" id="moves">—</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="act" id="undo">悔一步</button>
|
||||
<button class="act" id="reset">重来</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<b>这个页面在验证什么</b><br />
|
||||
① 色板、字体、<code>--u</code> 基准单位全部来自框架的
|
||||
<code>src/brand/</code>,一个文件都没复制,只覆盖了两处「视频专用」的设置。<br />
|
||||
② 渲染函数是纯函数 <code>render(state)</code>:给状态出画面,不依赖之前发生过什么。
|
||||
视频组件里是 <code>render(t)</code>,同一个套路。<br />
|
||||
③ 没有时间轴,没有渲染引擎,不依赖 ffmpeg —— 就是一个普通网页。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
/* ==================================================================
|
||||
三段式:状态 → 规则 → 渲染 → 驱动
|
||||
------------------------------------------------------------------
|
||||
这个结构是刻意和视频组件对齐的,照着它写,以后两边能互相搬。
|
||||
================================================================== */
|
||||
|
||||
const LINES = [
|
||||
[0, 1, 2], [3, 4, 5], [6, 7, 8], // 横
|
||||
[0, 3, 6], [1, 4, 7], [2, 5, 8], // 竖
|
||||
[0, 4, 8], [2, 4, 6], // 斜
|
||||
];
|
||||
|
||||
/* ---- 1. 状态:唯一真值 ----------------------------------------
|
||||
整个页面的信息都在这个对象里。改它,然后重渲染,就够了。 */
|
||||
const initial = () => ({
|
||||
cells: Array(9).fill(null), // null | "X" | "O"
|
||||
turn: "X",
|
||||
over: false,
|
||||
winner: null, // null | "X" | "O" | "draw"
|
||||
win: [], // 赢的那三个格子的下标,用来高亮
|
||||
history: [], // 落子顺序,悔一步要用
|
||||
});
|
||||
|
||||
let state = initial();
|
||||
|
||||
/* ---- 2. 规则:纯函数 old -> new -------------------------------
|
||||
注意它不碰 DOM,也不改传进来的对象。
|
||||
这样「同一步永远得到同一个结果」,也就好测试、好回放。 */
|
||||
function play(s, i) {
|
||||
if (s.over || s.cells[i]) return s; // 非法落子:原样返回
|
||||
if (i < 0 || i > 8) return s;
|
||||
|
||||
const cells = s.cells.slice();
|
||||
cells[i] = s.turn;
|
||||
|
||||
const win = LINES.find(([a, b, c]) => cells[a] && cells[a] === cells[b] && cells[b] === cells[c]);
|
||||
|
||||
const next = {
|
||||
...s,
|
||||
cells,
|
||||
history: [...s.history, i],
|
||||
winner: win ? cells[win[0]] : null,
|
||||
win: win || [],
|
||||
over: !!win || cells.every(Boolean),
|
||||
turn: s.turn === "X" ? "O" : "X",
|
||||
};
|
||||
|
||||
if (next.over && !next.winner) next.winner = "draw";
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 从零重放到指定的历史,用来支持悔一步 */
|
||||
function replay(moves) {
|
||||
let s = initial();
|
||||
for (const i of moves) s = play(s, i);
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ---- 3. 渲染:纯函数 state -> DOM -----------------------------
|
||||
和视频里的 render(t) 是同一个位置。
|
||||
这里不用 requestAnimationFrame,因为「什么时候重画」由驱动决定,
|
||||
不需要每帧都画 —— 网页天然就是事件驱动的。 */
|
||||
const boardEl = document.getElementById("board");
|
||||
const dotEl = document.getElementById("dot");
|
||||
const statusEl = document.getElementById("status");
|
||||
const movesEl = document.getElementById("moves");
|
||||
const undoEl = document.getElementById("undo");
|
||||
const resetEl = document.getElementById("reset");
|
||||
|
||||
// 建 9 个格子。建一次,之后只改它们的属性。
|
||||
const cellEls = [];
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const b = document.createElement("button");
|
||||
b.className = "cell";
|
||||
b.type = "button";
|
||||
b.setAttribute("role", "gridcell");
|
||||
b.addEventListener("click", () => {
|
||||
state = play(state, i);
|
||||
render(state);
|
||||
});
|
||||
boardEl.appendChild(b);
|
||||
cellEls.push(b);
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
cellEls.forEach((el, i) => {
|
||||
const mark = s.cells[i];
|
||||
el.textContent = mark || "";
|
||||
if (mark) el.dataset.mark = mark;
|
||||
else delete el.dataset.mark;
|
||||
|
||||
el.classList.toggle("win", s.win.includes(i));
|
||||
el.disabled = !!s.over || !!mark;
|
||||
el.setAttribute(
|
||||
"aria-label",
|
||||
mark ? `第 ${i + 1} 格 ${mark}` : `第 ${i + 1} 格,空`
|
||||
);
|
||||
});
|
||||
|
||||
const turn = s.over ? "-" : s.turn;
|
||||
dotEl.dataset.turn = turn;
|
||||
statusEl.textContent =
|
||||
s.winner === "draw" ? "平局"
|
||||
: s.over ? `${s.winner} 赢了`
|
||||
: `轮到 ${s.turn}`;
|
||||
|
||||
movesEl.textContent = s.history.length
|
||||
? s.history
|
||||
.map((i, k) => `${k + 1}.${k % 2 === 0 ? "X" : "O"}@${i + 1}`)
|
||||
.join(" ")
|
||||
: "—";
|
||||
|
||||
undoEl.disabled = !s.history.length;
|
||||
resetEl.disabled = !s.history.length;
|
||||
}
|
||||
|
||||
/* ---- 4. 驱动:状态从哪来 ---------------------------------------
|
||||
视频里这一格是 tl.time(),由渲染引擎的播放头给。
|
||||
网页里是点击和按键给。就换了这一个地方。 */
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const n = Number(e.key);
|
||||
if (n >= 1 && n <= 9) {
|
||||
state = play(state, n - 1);
|
||||
render(state);
|
||||
}
|
||||
});
|
||||
|
||||
undoEl.addEventListener("click", () => {
|
||||
state = replay(state.history.slice(0, -1));
|
||||
render(state);
|
||||
});
|
||||
|
||||
resetEl.addEventListener("click", () => {
|
||||
state = initial();
|
||||
render(state);
|
||||
});
|
||||
|
||||
render(state);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user