Files
xiaoshuai 9b63424740 chore: 初始化仓库,提交 vibevideo 源码
- src/: HyperFrames 封装 CLI(hv)、组件、品牌样式、音频(TTS/混音)工具
- projects/: 各项目 project.yaml 与文案
- interactive/: 井字棋演示页
- .gitignore: 忽略 node_modules、构建产物与 HyperFrames 运行期缓存
2026-10-12 18:06:20 +08:00

457 lines
15 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>