diff --git a/.changes/ui-deconstruct-redesign.md b/.changes/ui-deconstruct-redesign.md new file mode 100644 index 00000000..1ea68bbb --- /dev/null +++ b/.changes/ui-deconstruct-redesign.md @@ -0,0 +1,6 @@ +--- +"dropout": "patch:feat" +"@dropout/ui": "patch:feat" +--- + +Redesign the launcher as a deconstructed construction drawing with a frameless window, an exposed construction grid, oversized titles with a dropped glyph, overlapping system windows, a dithered sky, and hidden base64 easter eggs. The interface uses fixed pixels like the official Minecraft launcher: it follows only the system display scale, never the window size, and extra window space goes to the grid and whitespace. diff --git a/packages/ui/design/DECONSTRUCT.md b/packages/ui/design/DECONSTRUCT.md new file mode 100644 index 00000000..580fb26e --- /dev/null +++ b/packages/ui/design/DECONSTRUCT.md @@ -0,0 +1,150 @@ +# DropOut 界面重设计 · 解构 + +> 分支 `feat/ui-deconstruct`。粒子方块世界那一版(`feat/ui-redesign-prototype`)原样保留,不在这条分支上。 +> 参考:TypeSafe AI 官网(jev)。只借它的语法,不抄它的品牌。 + +## 0. 一句话 + +把启动器画成一张还没收拾干净的施工图:网格、轴号、裁切角、尺寸线都露在外面; +超大的字被切开、错位,其中一个字被「丢掉」只剩轮廓;窗口互相压着、微微歪斜; +有些文字表面上是一串 base64,复制下来解码才读得到。 + +DropOut 这个名字本来就是神经网络里的 dropout:训练时随机丢掉一部分神经元,剩下的更强。 +所以「丢掉」是贯穿全局的母题:被丢掉的字、被丢掉的方块、被丢掉的神经元,都只剩一圈虚线。 + +## 1. 为什么换掉粒子 + +维护者觉得粒子太花。粒子版的主角是一团会动的东西,界面退到细线和字后面; +这一版反过来:**没有主角,结构本身就是画面**。动的东西只剩两处小部件,都很慢, +而且在减弱动效和测试夹具里完全静止。 + +## 2. 尺寸规则(照官方启动器) + +官方 Minecraft 启动器的界面是**固定尺寸**的:字号、按钮、栏宽都是固定像素,只跟系统显示缩放(125%、150%)走 +(参考 Mojang 的 MCL-10940);窗口随便拉,不锁比例,只有最小尺寸;窗口变大时多出来的地方给画面, +界面本身不变大。具体像素数官方没公开,这里照的是规则。 + +- 界面全部是固定 CSS 像素:不用 `transform: scale()`,不调 webview 的 `setZoom()`,字号不按窗口宽高算(不用 `cqw`/`vw` 字号)。 + 系统缩放由 webview 的 `devicePixelRatio` 处理,不用写代码。 +- 窗口最小 905×575(`src-tauri/tauri.conf.json`),不锁比例、不吸附。无边框窗口的拖动、最小化 / 最大化 / 关闭、 + 右键菜单在 `src/lib/desktop-window.ts`;`src/components/shell/stage.tsx` 只是一个铺满窗口的根节点。 +- 窗口变大,多出来的空间这样用: + + | 部分 | 规则 | + | ---- | ---- | + | 施工网格 | 格子固定约 84×89(默认窗口内容区 1006×711 正好 12 × 8 格),铺满窗口,窗口越大格子越多;轴号按格数生成,列号 Z 之后接 AA、AB… | + | 首页 | 左栏固定 551 宽贴左,右边那叠窗口固定 388 宽贴右,中间是留白;抖色天空铺满宽度,高度固定 355.5 | + | 实例页 | 列表伸缩,右边详情栏固定 394;网格视图每张卡至少 260,窗口越宽列越多 | + | 助手页 | 对话区伸缩(气泡最宽 720),右边上下文栏固定 292 | + | 设置页 | 版心最宽 1006(默认窗口里的宽度),贴左,不跟着变宽 | + | 章节大数字 | 02 / 03 / 04 的右边缘钉在默认窗口里的位置,窗口窄时往左让,不压右上角的按钮 | + +- 放不下的时候在自己那一栏里滚动,不缩小字。最小窗口下首页右栏变窄(最窄约 268), + 两个窗口的错位收小,DAYLIGHT 小部件收起,页面纵向滚动。 +- 弹出层(对话框、下拉、菜单)挂在 body 上;对话框「不超过窗口」的约束用 `vh`/`vw`,这不算缩放。 +- 状态栏和 F3 的坐标就是窗口坐标;F3 里写窗口尺寸和 devicePixelRatio。 + +## 3. 解构手法对照 + +| 手法 | 在界面里的样子 | +| ---- | ---- | +| 暴露结构 | 全局施工网格(默认窗口 12 列 × 8 行,顶上 A–L、左边 1–8 轴号,窗口大了格子更多),四角套准记号 ⊕;首页标题下方一条实时量出来的尺寸线 `W 551` | +| 碎片 / 错位 | 标题除第一行外整体缩进半格;每句标题按固定规则丢掉一个字(只剩描边),并拉一条引线标注 `DROPPED · p=0.50` | +| 叠压 / 倾斜 | 首页右侧 INSTANCE.CFG、READINESS.SYS、两个小部件互相压着,小部件转 −2.2° / 1.6°;一块旋转的虚线框「被丢掉的方块」压在标题后面 | +| 印刷错版 | 当前实例、按钮悬停、输入框聚焦时,身后露出一道偏移的粉色色块 | +| 巨大的章节号 | 实例 02、助手 03、设置 04 的页头后面压一个空心大号数字,被页面切掉一部分 | +| 第二层文本 | 侧边竖排密文、`[B.64]` 块、状态栏中间那串,见第 7 节 | +| 字体冲突 | 挤紧的无衬线大字 + 像素等宽小字 + 等宽说明字,三种声音同时说话 | + +## 4. 从 jev 借来的 + +- 白色标签贴在色场上的导航(当前页反成黑底)。 +- 抖色(dither)色场:首页上半截是一块静态的像素云,往下散成纸面。 +- 复古系统窗口:黑色标题栏 + 像素字「文件名」(`INSTANCE.CFG`、`SESSION.LOG`…),1px 墨线、硬投影。 +- 引导点线 `标签 ……… 数值`、左边一道竖线的文字栏、`∵ x` 分节记号、分段进度条。 +- 超大的下划线链接当主操作(`▶▶▶ Launch`)。 +- 竖排 base64、`[B.64]` 块、页脚 base64 这类彩蛋。 + +jev 自己用的 Die Grotesk 和 LisaTerminal 是商业字体,这里换成开源替代(见第 5 节)。 + +## 5. 配色与字体 + +| 记号 | 纸面(浅) | 墨面(深) | 用途 | +| ---- | ---- | ---- | ---- | +| `paper` | `#eeece7` | `#171717` | 背景 | +| `sheet` | `#fbfaf7` | `#1f1f1f` | 窗口、卡片 | +| `ink` / `rule` | `#1e1e1e` | `#eceae4` | 文字、1px 墨线 | +| `win` | `#dedede` | `#2c2c2c` | 窗口里的灰色控制区 | +| `signal` | `#f386a1` | 同 | 错版色块、当前项小方块 | +| `go` | `#43b581` | `#4cc48c` | 启动、就绪(DropOut 图标原本的绿) | +| `sky` / `warn` / `fault` | 蓝 / 黄 / 红 | 同 | 天空、警告、错误 | + +`go-ink` / `warn-ink` / `fault-ink` 是同色系的深色版,专门给文字用,保证对比度。 +圆角全部为 0。shadcn 的 `primary` 映射成墨色,`accent`(菜单高亮)是反白。 + +字体全部本地打包,不走 CDN(Tauri 的 CSP 也不允许): + +- 大标题:Inter Tight(`type-display`,字距 −0.055em,行距 0.86;中文自动放宽) +- 正文:Inter +- 像素小字:Departure Mono(OFL,文件和授权在 `src/assets/fonts/departure-mono/`) +- 等宽:JetBrains Mono +- 中文回落到系统字体(苹方 / 雅黑 / Noto Sans CJK) + +## 6. 构件 + +```text +src/index.css 记号、字体、.win/.win-title/.tag/.chip/.crop/.dots/.leader/.dropped/.misreg 等 +src/lib/desktop-window.ts 无边框窗口:拖动、最小化 / 最大化 / 关闭、右键菜单 +src/components/shell/ + stage.tsx 铺满窗口的根节点 + top-nav.tsx 顶栏标签导航 + 账号 + construction-grid.tsx 施工网格(固定格子)、按格数生成的标尺、竖排密文 + status-strip.tsx 底栏(系统名、版本、路由、base64、鼠标坐标) + easter-eggs.tsx ↑↑↓↓←→←→BA 解构模式(左下角提示窗)、F3 调试信息、控制台招呼 + logo.tsx 标志(神经网络,两个节点被丢掉)+ 字标 Drop○ut +src/components/deco/ + win.tsx 系统窗口 + display-title.tsx 丢一个字的超大标题 + dither-sky.tsx 抖色天空(静态,按尺寸和主题重画) + widgets.tsx DROPOUT 神经元小部件、DAYLIGHT 游戏时钟 + marks.tsx 引导点线、分段条、尺寸线、引线标注、套准记号、[B.64]、竖排密文 + page-header.tsx 二级页面页头 +src/lib/eggs.ts 所有彩蛋的内容 +``` + +shadcn 基础组件(按钮、对话框、下拉、选择、选项卡、开关…)直接改了样式, +所以向导、编辑器、弹窗这些没单独重写的地方也跟着换了语言。对话框统一成系统窗口, +标题栏默认写 `DIALOG.SYS`,可以用 `chrome` 换。 + +## 7. 彩蛋 + +| 位置 | 内容 | +| ---- | ---- | +| 左侧竖排那串 | base64 套了六层,最后是 *Never dig straight down.* | +| 设置页右上 `[B.64]` | dropout 本身的代码(Srivastava 2014),注释 *drop half. the rest get stronger.* | +| 助手页底部 `[B.64]` | 一张 16×16 灰度 PNG:苦力怕的脸(对应 jev 藏的 MNIST 手写数字) | +| 状态栏中间 | *DropOut, the next-generation Minecraft launcher.* | +| ↑↑↓↓←→←→BA | 解构模式:所有盒子的边界都描出来,背景抽掉,露出层层叠压。左下角(首页日志窗下面那块空白)弹出一扇固定 360×96 的 `DECONSTRUCT.EXE` 小窗,带图例;再输一次、按 Esc 或点 × 退出 | +| F3 | Minecraft 式调试信息(帧率、坐标、区块、路由、实例) | +| DROPOUT 小部件 | 点一下换丢弃率 p(0.1 → 0.9) | +| DAYLIGHT 小部件 | 现实时间换算成游戏时间,第几天从 Minecraft 正式版发布那天算 | +| 浏览器控制台 | 一行像素字 DROP OUT 和上面两个按键提示 | + +## 8. 测试 + +- 功能测试全部通过(可访问性、键盘、各种启动状态、迁移、助手…)。 +- 新增「界面不随窗口缩放」:905×575、1024×768、1280×720、1920×1080、2560×1440 五种窗口下, + 首页标题、启动按钮、导航的 computed font-size 和尺寸完全一样,左栏和右栏不重叠,没有横向滚动。 +- 助手页图片彩蛋那条:最小窗口里它在页面折线以下,测试先把它滚到中间,再检查完整可见、能复制。 +- 截图基线全部重画:只重新生成了 Linux 的(CI 跑在 Ubuntu 上),macOS 的旧基线没动。 + - default-window(1024×768)基本不变,只有字号取整带来的亚像素差异(标题 73.7 → 74 这类);网格和天空逐像素一致。 + - minimum-window 改回 905×575,从「整张画板缩小」变成 1:1 排版,放不下的部分要滚动才看得到。 +- 截图要逐像素一致,所以夹具模式下:时钟固定在一个时刻、DROPOUT 小部件不跳、抖色天空用固定种子。 +- 系统缩放:用 `deviceScaleFactor: 1.5` 截图,整个界面按 1.5 倍放大,排版和 1 倍一样。 + +## 9. 没做 / 下一步 + +1. Tauri 窗口里的 WebKitGTK 还没实测(抖色天空是 2D canvas,风险比粒子小得多);拉窗口、最大化也只在浏览器里按尺寸验过。 +2. 浏览页(Modrinth)只换了页头和列表行,卡片可以再做得更像「目录卡」。 +3. 实例编辑弹窗、导入/转换向导只换了基础组件和颜色,没有逐屏打磨。 +4. 设置里可以加一个「施工网格」开关,给觉得线太多的人关掉。 diff --git a/packages/ui/package.json b/packages/ui/package.json index 3f730ff3..41c5a115 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,6 +14,9 @@ }, "dependencies": { "@base-ui/react": "^1.8.0", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/inter-tight": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@hookform/resolvers": "^5.9.1", "@stepperize/react": "^8.0.0", "@tauri-apps/api": "^2.11.1", diff --git a/packages/ui/src/assets/fonts/departure-mono/DepartureMono-Regular.woff2 b/packages/ui/src/assets/fonts/departure-mono/DepartureMono-Regular.woff2 new file mode 100644 index 00000000..b4a23dc1 Binary files /dev/null and b/packages/ui/src/assets/fonts/departure-mono/DepartureMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/departure-mono/LICENSE b/packages/ui/src/assets/fonts/departure-mono/LICENSE new file mode 100644 index 00000000..de524761 --- /dev/null +++ b/packages/ui/src/assets/fonts/departure-mono/LICENSE @@ -0,0 +1,93 @@ +Copyright 2022–2024 Helena Zhang (helenazhang.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index fbc1d6a3..f61031ac 100644 --- a/packages/ui/src/components/bottom-bar.tsx +++ b/packages/ui/src/components/bottom-bar.tsx @@ -99,10 +99,7 @@ export function BottomBar() { return (
{preview && unresolvedCount > 0 && ( - + {t("conversion.blocked", { count: unresolvedCount })} )} diff --git a/packages/ui/src/components/deco/display-title.tsx b/packages/ui/src/components/deco/display-title.tsx new file mode 100644 index 00000000..efddbd89 --- /dev/null +++ b/packages/ui/src/components/deco/display-title.tsx @@ -0,0 +1,66 @@ +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +function hash(text: string) { + let value = 2166136261; + for (const char of text) { + value ^= char.codePointAt(0) ?? 0; + value = Math.imul(value, 16777619); + } + return value >>> 0; +} + +/** 选一个要「丢掉」的字:不选第一个字,不选空格和标点;同一句话总是丢同一个。 */ +export function droppedGlyphIndex(text: string) { + const glyphs = Array.from(text); + const candidates = glyphs + .map((glyph, index) => ({ glyph, index })) + .filter(({ glyph, index }) => index > 0 && /\p{L}/u.test(glyph)) + .map(({ index }) => index); + if (glyphs.length < 4 || candidates.length === 0) return -1; + return candidates[hash(text) % candidates.length] ?? -1; +} + +/* + * 超大标题。一个字被丢掉,只剩轮廓(dropout); + * 除第一行外,后面的行整体缩进,像被推开了半格。 + * 读屏和测试读到的仍是完整的一句话。 + */ +export function DisplayTitle({ + as: Tag = "h1", + text, + drop = true, + stagger = true, + className, + ...props +}: React.HTMLAttributes & { + ref?: React.Ref; + as?: "h1" | "h2" | "h3" | "p"; + text: string; + drop?: boolean; + stagger?: boolean; +}) { + const glyphs = Array.from(text); + const dropped = drop ? droppedGlyphIndex(text) : -1; + + return ( + + {dropped < 0 + ? text + : [ + glyphs.slice(0, dropped).join(""), + + {glyphs[dropped]} + , + glyphs.slice(dropped + 1).join(""), + ]} + + ); +} diff --git a/packages/ui/src/components/deco/dither-sky.tsx b/packages/ui/src/components/deco/dither-sky.tsx new file mode 100644 index 00000000..6edf5e86 --- /dev/null +++ b/packages/ui/src/components/deco/dither-sky.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; + +/* + * 抖色天空:一块静态的像素云,按窗口大小画一次,主题变了再画一次。 + * 有序抖色(4×4 Bayer)叠一点固定种子的颗粒;越往下越稀,最后散成纸面。 + * 不做动画——上一版的粒子太闹,这里只要一块安静的色场。 + */ + +const PIXEL = 2; + +const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map( + (value) => (value + 0.5) / 16, +); + +type Rgb = [number, number, number]; + +const PALETTES: Record<"light" | "dark", { sky: Rgb; cloud: Rgb; haze: Rgb }> = + { + light: { + sky: [164, 196, 236], + cloud: [245, 160, 182], + haze: [212, 188, 240], + }, + dark: { + sky: [36, 46, 66], + cloud: [96, 50, 74], + haze: [60, 52, 90], + }, + }; + +function hash2(x: number, y: number, seed: number) { + let h = (x * 374761393 + y * 668265263 + seed * 2147483647) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967295; +} + +function valueNoise(x: number, y: number, seed: number) { + const xi = Math.floor(x); + const yi = Math.floor(y); + const xf = x - xi; + const yf = y - yi; + const u = xf * xf * (3 - 2 * xf); + const v = yf * yf * (3 - 2 * yf); + const a = hash2(xi, yi, seed); + const b = hash2(xi + 1, yi, seed); + const c = hash2(xi, yi + 1, seed); + const d = hash2(xi + 1, yi + 1, seed); + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; +} + +function fbm(x: number, y: number, seed: number) { + let sum = 0; + let amp = 0.5; + let freq = 1; + for (let octave = 0; octave < 5; octave += 1) { + sum += amp * valueNoise(x * freq, y * freq, seed + octave * 17); + freq *= 2.03; + amp *= 0.5; + } + return sum; +} + +function smoothstep(edge0: number, edge1: number, x: number) { + const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +function paint(canvas: HTMLCanvasElement, seed: number, dark: boolean) { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(1, Math.ceil(rect.width / PIXEL)); + const height = Math.max(1, Math.ceil(rect.height / PIXEL)); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return; + + const palette = PALETTES[dark ? "dark" : "light"]; + const image = context.createImageData(width, height); + const data = image.data; + const scale = 1 / 60; + + for (let y = 0; y < height; y += 1) { + const v = y / height; + for (let x = 0; x < width; x += 1) { + const threshold = + BAYER[(y % 4) * 4 + (x % 4)] * 0.72 + hash2(x, y, seed + 99) * 0.28; + + // 云:上半部多、往两侧堆积,中间留出一块天 + const n = fbm(x * scale, y * scale * 1.5, seed); + const side = Math.abs(x / width - 0.48) * 0.32; + const lift = 0.08 - v * 0.26 + side; + const cloud = smoothstep(0.44, 0.7, n + lift); + const haze = 1 - Math.abs(cloud - 0.5) * 2; + + // 往下散成纸面;云心里也透出一点纸色,像印得不实 + const fade = smoothstep(0.18, 0.92, v + (n - 0.5) * 0.45); + if (fade > threshold) continue; + if (cloud > 0.9 && hash2(x, y, seed + 3) < 0.22) continue; + + let color = palette.sky; + if (cloud > threshold) color = palette.cloud; + if (haze > 0.5 && hash2(x, y, seed + 7) < haze * 0.55) { + color = palette.haze; + } + + const offset = (y * width + x) * 4; + data[offset] = color[0]; + data[offset + 1] = color[1]; + data[offset + 2] = color[2]; + data[offset + 3] = 255; + } + } + + context.putImageData(image, 0, 0); +} + +export function DitherSky({ + seed = 11, + className, +}: { + seed?: number; + className?: string; +}) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + let frame = 0; + const isDark = () => document.documentElement.classList.contains("dark"); + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => paint(canvas, seed, isDark())); + }; + + schedule(); + + const resize = + "ResizeObserver" in window ? new ResizeObserver(schedule) : null; + resize?.observe(canvas); + + const themeWatcher = new MutationObserver(schedule); + themeWatcher.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => { + cancelAnimationFrame(frame); + resize?.disconnect(); + themeWatcher.disconnect(); + }; + }, [seed]); + + return ( + + ); +} diff --git a/packages/ui/src/components/deco/marks.tsx b/packages/ui/src/components/deco/marks.tsx new file mode 100644 index 00000000..0716c171 --- /dev/null +++ b/packages/ui/src/components/deco/marks.tsx @@ -0,0 +1,326 @@ +import type * as React from "react"; +import { useEffect, useState } from "react"; +import { CIPHER_VERTICAL, chunk } from "@/lib/eggs"; +import { cn } from "@/lib/utils"; + +/** 分节记号:∵ x / x ∵ */ +export function SectionMark({ + side = "left", + className, +}: { + side?: "left" | "right"; + className?: string; +}) { + return ( + + ); +} + +/** 套准记号 ⊕ */ +export function RegMark({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + return ( + + ); +} + +/** 标签 ……… 数值 */ +export function Leader({ + label, + value, + className, + valueClassName, +}: { + label: React.ReactNode; + value: React.ReactNode; + className?: string; + valueClassName?: string; +}) { + return ( +
+ {label} +
+ ); +} + +/** 分段条:十格,填到哪算哪,最后一格按比例填一部分 */ +export function SegBar({ + value, + segments = 10, + className, + fillClassName = "bg-ink", +}: { + value: number; + segments?: number; + className?: string; + fillClassName?: string; +}) { + const clamped = Math.max(0, Math.min(1, value)); + return ( + + ); +} + +/** 尺寸标注线:量出目标元素的宽度,画在它上方 */ +export function DimensionLine({ + target, + className, + prefix = "W", +}: { + target: React.RefObject; + className?: string; + prefix?: string; +}) { + const [width, setWidth] = useState(null); + + useEffect(() => { + const node = target.current; + if (!node) return; + const update = () => setWidth(node.offsetWidth); + update(); + if (!("ResizeObserver" in window)) return; + const observer = new ResizeObserver(update); + observer.observe(node); + return () => observer.disconnect(); + }, [target]); + + return ( + + ); +} + +/** 彩蛋的复制:文字不可选中,点一下才复制,原处闪一下 */ +function useCopyFlash(text: string) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1200); + return () => window.clearTimeout(timer); + }, [copied]); + const copy = () => { + void navigator.clipboard + ?.writeText(text) + .then(() => setCopied(true)) + .catch(() => undefined); + }; + return { copied, copy }; +} + +/** 可以点一下复制的一串字(只给鼠标用,不进 Tab 顺序,读屏跳过) */ +export function CopyText({ + text, + className, + copiedClassName = "bg-ink text-paper", + children, +}: { + text: string; + className?: string; + copiedClassName?: string; + children?: React.ReactNode; +}) { + const { copied, copy } = useCopyFlash(text); + return ( + + ); +} + +/** 竖排的一串 base64(彩蛋)。点一下复制,解六次。 */ +export function VerticalCipher({ className }: { className?: string }) { + return ( + + ); +} + +/** [B.64] 块(彩蛋)。点一下复制。 */ +export function B64Block({ + data, + width = 30, + label = "[B.64]", + className, +}: { + data: string; + width?: number; + label?: string; + className?: string; +}) { + const { copied, copy } = useCopyFlash(data); + return ( + + ); +} + +/* + * 引线标注:从标题里被丢掉的那个字拉出一条折线,写上 DROPPED。 + * 标题的父元素要是 relative,标注按它定位。 + */ +export function GlyphCallout({ + target, + label, + className, +}: { + target: React.RefObject; + label: string; + className?: string; +}) { + const [point, setPoint] = useState<{ x: number; y: number } | null>(null); + + useEffect(() => { + const node = target.current; + const container = node?.parentElement; + if (!node || !container) return; + + const update = () => { + const glyph = node.querySelector("[data-dropped]"); + if (!glyph) { + setPoint(null); + return; + } + const g = glyph.getBoundingClientRect(); + const c = container.getBoundingClientRect(); + setPoint({ + x: Math.round(g.left - c.left + g.width * 0.7), + y: Math.round(g.top - c.top + g.height * 0.22), + }); + }; + + update(); + void document.fonts?.ready.then(update); + const resize = + "ResizeObserver" in window ? new ResizeObserver(update) : null; + resize?.observe(node); + const mutation = new MutationObserver(update); + mutation.observe(node, { + childList: true, + subtree: true, + characterData: true, + }); + return () => { + resize?.disconnect(); + mutation.disconnect(); + }; + }, [target]); + + if (!point) return null; + + return ( + + ); +} diff --git a/packages/ui/src/components/deco/page-header.tsx b/packages/ui/src/components/deco/page-header.tsx new file mode 100644 index 00000000..5e71f026 --- /dev/null +++ b/packages/ui/src/components/deco/page-header.tsx @@ -0,0 +1,69 @@ +import { ArrowLeftIcon } from "lucide-react"; +import type * as React from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +/* + * 页头:白标签 + 超大标题,背后压一个空心的章节号。 + * 二级页面左边多一个返回按钮。 + */ +export function PageHeader({ + index, + eyebrow, + title, + onBack, + backLabel, + actions, + className, +}: { + index?: string; + eyebrow?: React.ReactNode; + title: React.ReactNode; + onBack?: () => void; + backLabel?: string; + actions?: React.ReactNode; + className?: string; +}) { + return ( +
+ {index && ( + + )} +
+ {onBack && ( + + )} +
+ {eyebrow &&

{eyebrow}

} +

+ {title} +

+
+
+ {actions && ( +
+ {actions} +
+ )} +
+ ); +} diff --git a/packages/ui/src/components/deco/widgets.tsx b/packages/ui/src/components/deco/widgets.tsx new file mode 100644 index 00000000..45d5124f --- /dev/null +++ b/packages/ui/src/components/deco/widgets.tsx @@ -0,0 +1,165 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { isLauncherFixtureMode } from "@/lib/launcher-runtime"; +import { cn } from "@/lib/utils"; +import { Win } from "./win"; + +function prefersReducedMotion() { + return ( + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +function mulberry32(seed: number) { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const RATES = [0.1, 0.25, 0.5, 0.75, 0.9] as const; + +/* + * DROPOUT 小部件:一排神经元,每隔一会儿按 p 随机丢掉一部分(只剩虚线框)。 + * 点一下换一个 p。这是 DropOut 名字的来历:训练时随机丢掉神经元,剩下的更强。 + */ +export function DropoutWidget({ + columns = 8, + rows = 4, + className, +}: { + columns?: number; + rows?: number; + className?: string; +}) { + const { t } = useTranslation(); + const frozen = isLauncherFixtureMode() || prefersReducedMotion(); + const [rateIndex, setRateIndex] = useState(2); + const [tick, setTick] = useState(0); + const rate = RATES[rateIndex] ?? 0.5; + const total = columns * rows; + + useEffect(() => { + if (frozen) return; + const timer = window.setInterval(() => setTick((value) => value + 1), 1400); + return () => window.clearInterval(timer); + }, [frozen]); + + const mask = useMemo(() => { + const random = mulberry32(0x5eed + tick * 7919 + rateIndex * 31); + return Array.from({ length: total }, () => random() >= rate); + }, [rate, rateIndex, tick, total]); + + const kept = mask.filter(Boolean).length; + const percent = Math.round(rate * 100); + + return ( + + + + ); +} + +/* 夹具模式下固定在这一刻,截图才能逐像素一致 */ +const FIXTURE_NOW = Date.UTC(2026, 9, 24, 4, 30, 0); +const MINECRAFT_RELEASE = Date.UTC(2011, 10, 18); + +function minecraftClock(now: number) { + const ticks = Math.floor(now / 50) % 24000; + const day = Math.floor((now - MINECRAFT_RELEASE) / 1_200_000); + // 第 0 刻是早上六点 + const minutesOfDay = ((ticks / 1000 + 6) % 24) * 60; + const hours = Math.floor(minutesOfDay / 60); + const minutes = Math.floor(minutesOfDay % 60); + const daytime = ticks < 12542 || ticks > 23460; + return { ticks, day, hours, minutes, daytime }; +} + +/* + * DAYLIGHT 小部件:把现实时间换算成游戏里的时间(一天二十分钟), + * 第几天从 Minecraft 正式版发布那天算起。 + */ +export function DaylightWidget({ className }: { className?: string }) { + const { t, i18n } = useTranslation(); + const frozen = isLauncherFixtureMode(); + const [now, setNow] = useState(() => (frozen ? FIXTURE_NOW : Date.now())); + + useEffect(() => { + if (frozen) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [frozen]); + + const clock = minecraftClock(now); + const time = `${String(clock.hours).padStart(2, "0")}:${String(clock.minutes).padStart(2, "0")}`; + const day = new Intl.NumberFormat(i18n.resolvedLanguage ?? "en").format( + clock.day, + ); + + return ( + +
+

+ {t("deco.day", { day })} +

+

+ {t("deco.gameTime", { time })} + +

+