feat: 新增 page3 演职员表并拆分多页资源

- 新增 page3.html:无「天」背景的演职员表与火山场景
- 重构为页面专属 CSS/JS 与共用的 text-scene 渲染引擎
- PDF 导出支持 --page 参数
This commit is contained in:
rogerwang2008 committed 2026-11-11 00:13:14 +08:00
1 parent b9ed01d91b
commit 319ba1890a
11 files changed
+539 -258

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
/* 第 1 页场景数据
渲染引擎见 text-scene.js;坐标范围 x 0–27,y 0–43。 */
(() => {
/* 云:x/y 为最宽一行的左上角,三行 3/5/3;上下拉开分布 */
const clouds = [
{ x: 10, y: 2, tone: 'pink' },
{ x: 18, y: 6, tone: 'violet' },
{ x: 1, y: 10, tone: 'orange' },
{ x: 23, y: 13, tone: 'lilac' },
{ x: 4, y: 16, tone: 'rose' },
];
/* 落日:整体下移贴近楼群,形成日落;
色调按数组顺序,顶部偏红 → 底部偏黄 */
const sun = [
{ y: 18, x: 13, w: 2 },
{ y: 19, x: 10, w: 8 },
{ y: 20, x: 9, w: 10 },
{ y: 21, x: 8, w: 12 },
{ y: 22, x: 7, w: 14 },
{ y: 23, x: 7, w: 14 },
{ y: 24, x: 6, w: 16 },
{ y: 25, x: 6, w: 16 },
{ y: 26, x: 6, w: 16 },
{ y: 27, x: 6, w: 16 },
{ y: 28, x: 6, w: 16 },
{ y: 29, x: 7, w: 14 },
{ y: 30, x: 7, w: 14 },
{ y: 31, x: 8, w: 12 },
{ y: 32, x: 9, w: 10 },
{ y: 33, x: 10, w: 8 },
{ y: 34, x: 13, w: 2 },
];
/* 飞鸟:数量减少,间距不等,仅少量成对;上下拉开分布 */
const birds = [
{ x: 25, y: 2, kind: 'magpie' },
{ x: 1, y: 4, kind: 'sparrow' },
{ x: 3, y: 8, kind: 'crow' },
{ x: 9, y: 13, kind: 'magpie' },
{ x: 9, y: 15, kind: 'sparrow' },
{ x: 26, y: 5, kind: 'crow' },
{ x: 17, y: 9, kind: 'sparrow' },
{ x: 21, y: 12, kind: 'magpie' },
{ x: 19, y: 15, kind: 'crow' },
];
/* 天线:单列「线」,从 y 向下叠 h 格;最右楼顶另加一根深色 */
const antennas = [
{ x: 7, y: 28, h: 3 },
{ x: 22, y: 27, h: 4 },
{ x: 26, y: 34, h: 3, cls: 'ant ant-dark' },
];
/* 楼群:n 对应 .bld-n,最终按 x/y/w/h 铺满「楼」(高度裁到第 44 行) */
const buildings = [
{ n: 1, x: 0, y: 35, w: 3, h: 9 },
{ n: 2, x: 3, y: 39, w: 3, h: 5 },
{ n: 3, x: 6, y: 31, w: 4, h: 13 },
{ n: 4, x: 10, y: 36, w: 3, h: 8 },
{ n: 5, x: 13, y: 30, w: 4, h: 14 },
{ n: 6, x: 17, y: 34, w: 3, h: 10 },
{ n: 7, x: 20, y: 31, w: 4, h: 13 },
{ n: 8, x: 24, y: 37, w: 4, h: 7 },
];
/* 标题区:整格扣出的居中矩形,左右各留 2 格 */
const title = {
text: '关于「甲子」的虚构故事',
emphasis: '甲子',
top: 14,
height: 4,
side: 2,
};
TextScene.render({
cols: 28,
rows: 44,
clouds,
sun,
birds,
antennas,
buildings,
title,
});
})();
+134
View File
@@ -0,0 +1,134 @@
/* 第 3 页场景数据
前景:演职员表;背景:电线、麻雀、枪、草。
本页不铺「天」,空白格保持为空。 */
(() => {
/* 固定种子的噪声,保证每次渲染一致 */
const noise = (x, y) => {
let h = Math.imul(x, 374761393) + Math.imul(y, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};
/* 火山:占据下半屏,下 2/3 为山体、上 1/3 为熔岩棋盘格 */
const volcano = [];
const VOLCANO_TOP = 21;
const VOLCANO_BOTTOM = 42;
/* 每列的熔岩下沿,形成不规则的熔岩流 */
const lavaBottom = Array.from({ length: 28 }, (_, x) => {
return VOLCANO_TOP + 6 + Math.floor(noise(x, 3) * 3);
});
[5, 13, 21].forEach((x) => {
lavaBottom[x] += 4 + Math.floor(noise(x, 9) * 3);
});
/* 两层熔岩的分界:按列小幅随机起伏 */
const lavaSplit = Array.from({ length: 28 }, (_, x) => {
const offset = Math.round((noise(x, 13) - 0.5) * 4);
return Math.max(
VOLCANO_TOP + 2,
Math.min(VOLCANO_TOP + 5, VOLCANO_TOP + 3 + offset)
);
});
/* 主熔岩区以下为山体,两侧做对称平滑处理 */
const ROCK_START = VOLCANO_TOP + 7;
for (let y = VOLCANO_TOP; y <= VOLCANO_BOTTOM; y++) {
/* 距底部 5 格起铺满整幅宽度,避免山体过陡 */
const fullWidthAt = VOLCANO_BOTTOM - 5;
const t = (y - VOLCANO_TOP) / (fullWidthAt - VOLCANO_TOP);
const baseWidth =
y >= fullWidthAt ? 28 : Math.round(10 + 18 * Math.min(1, Math.max(0, t)));
let width;
let left;
if (y < ROCK_START) {
/* 熔岩区保持原有随机形状 */
const jitter =
y >= fullWidthAt ? 0 : Math.round((noise(0, y) - 0.5) * 2);
width = Math.max(4, Math.min(28, baseWidth + jitter));
left = Math.floor((28 - width) / 2);
left += Math.round((noise(1, y) - 0.5) * 2);
left = Math.max(0, Math.min(28 - width, left));
if (y === VOLCANO_TOP + 1) left = Math.max(0, left - 1);
} else {
/* 山体左右对称,宽度取偶数和,保留低频小幅随机 */
const wobble = Math.round((noise(7, Math.floor(y / 3)) - 0.5) * 2);
let w = y >= fullWidthAt ? 28 : Math.min(28, baseWidth + wobble);
w -= w % 2;
width = Math.max(4, w);
left = (28 - width) / 2;
}
/* 顶部火山口:仅两行凹下,第一行缺口更宽 */
const craterGap = y < ROCK_START ? [4, 2][y - VOLCANO_TOP] || 0 : 0;
const gapStart = craterGap ? Math.floor((width - craterGap) / 2) : 0;
for (let i = 0; i < width; i++) {
if (craterGap && i >= gapStart && i < gapStart + craterGap) continue;
const x = left + i;
const isLava = y <= lavaBottom[x];
const isUpperLava = isLava && y <= lavaSplit[x];
volcano.push({
text: isLava ? ((x + y) % 2 === 0 ? '熔' : '岩') : '石',
x,
y,
cls: isLava ? (isUpperLava ? 'lava-a' : 'lava-b') : 'rock',
});
}
}
/* 第十行左侧补一个「熔」 */
const row10 = volcano.filter((cell) => cell.y === VOLCANO_TOP + 9);
const row10Left = Math.min(...row10.map((cell) => cell.x));
volcano.push({
text: '熔',
x: row10Left - 1,
y: VOLCANO_TOP + 9,
cls: 'lava-b',
});
TextScene.render({
cols: 28,
rows: 44,
skyChar: '',
glyphs: [
/* 电线:整行贯穿页面 */
{ text: '电线'.repeat(14), x: 0, y: 8, cls: 'wire' },
/* 麻雀:竖排于从左到右第四格,落在电线上方 */
{ text: '麻雀', x: 3, y: 6, cls: 'sparrow', vertical: true },
/* 火山铺在底部背景,枪、草、消防栓在其上层 */
...volcano,
/* 枪:多个「枪」拼出枪形,占最右 4 列,枪口指向左上方的麻雀 */
{ text: '枪', x: 24, y: 20, cls: 'gun' },
{ text: '枪', x: 25, y: 20, cls: 'gun' },
{ text: '枪', x: 25, y: 21, cls: 'gun' },
{ text: '枪', x: 26, y: 21, cls: 'gun' },
{ text: '枪', x: 26, y: 22, cls: 'gun' },
{ text: '枪', x: 27, y: 22, cls: 'gun' },
{ text: '枪', x: 27, y: 23, cls: 'gun' },
{ text: '枪', x: 27, y: 24, cls: 'gun' },
/* 草:最底部一排 */
{ text: '草'.repeat(28), x: 0, y: 43, cls: 'grass' },
/* 消防栓:最后三行分散竖排九根,左侧留 2 格 */
...[2, 5, 8, 10, 13, 16, 19, 23, 25].map((x) => ({
text: '消防栓',
x,
y: 41,
cls: 'hydrant',
vertical: true,
})),
],
});
const scene = document.querySelector('.scene');
const credits = document.createElement('h1');
credits.className = 'credits';
credits.textContent = '演职员表';
scene.append(credits);
})();
-170
View File
@@ -1,170 +0,0 @@
/* 落日文字画 · 28 × 44 网格
构图全部由下方数组描述;空白格自动用「天」铺满。
修改元素只需调整数组中的坐标与尺寸,行列范围:x 0–27,y 0–43。 */
(() => {
const COLS = 28;
const ROWS = 44;
/* 云:x/y 为最宽一行的左上角,三行 3/5/3;上下拉开分布 */
const clouds = [
{ x: 10, y: 2, tone: 'pink' },
{ x: 18, y: 6, tone: 'violet' },
{ x: 1, y: 10, tone: 'orange' },
{ x: 23, y: 13, tone: 'lilac' },
{ x: 4, y: 16, tone: 'rose' },
];
/* 落日:整体下移贴近楼群,形成日落;
色调按数组顺序,顶部偏红 → 底部偏黄 */
const sun = [
{ y: 18, x: 13, w: 2 },
{ y: 19, x: 10, w: 8 },
{ y: 20, x: 9, w: 10 },
{ y: 21, x: 8, w: 12 },
{ y: 22, x: 7, w: 14 },
{ y: 23, x: 7, w: 14 },
{ y: 24, x: 6, w: 16 },
{ y: 25, x: 6, w: 16 },
{ y: 26, x: 6, w: 16 },
{ y: 27, x: 6, w: 16 },
{ y: 28, x: 6, w: 16 },
{ y: 29, x: 7, w: 14 },
{ y: 30, x: 7, w: 14 },
{ y: 31, x: 8, w: 12 },
{ y: 32, x: 9, w: 10 },
{ y: 33, x: 10, w: 8 },
{ y: 34, x: 13, w: 2 },
];
/* 飞鸟:数量减少,间距不等,仅少量成对;上下拉开分布 */
const birds = [
{ x: 25, y: 2, kind: 'magpie' },
{ x: 1, y: 4, kind: 'sparrow' },
{ x: 3, y: 8, kind: 'crow' },
{ x: 9, y: 13, kind: 'magpie' },
{ x: 9, y: 15, kind: 'sparrow' },
{ x: 26, y: 5, kind: 'crow' },
{ x: 17, y: 9, kind: 'sparrow' },
{ x: 21, y: 12, kind: 'magpie' },
{ x: 19, y: 15, kind: 'crow' },
];
/* 天线:单列「线」,从 y 向下叠 h 格;最右楼顶另加一根深色 */
const antennas = [
{ x: 7, y: 28, h: 3 },
{ x: 22, y: 27, h: 4 },
{ x: 26, y: 34, h: 3, cls: 'ant ant-dark' },
];
/* 楼群:n 对应 .bld-n,最终按 x/y/w/h 铺满「楼」(高度裁到第 44 行) */
const buildings = [
{ n: 1, x: 0, y: 35, w: 3, h: 9 },
{ n: 2, x: 3, y: 39, w: 3, h: 5 },
{ n: 3, x: 6, y: 31, w: 4, h: 13 },
{ n: 4, x: 10, y: 36, w: 3, h: 8 },
{ n: 5, x: 13, y: 30, w: 4, h: 14 },
{ n: 6, x: 17, y: 34, w: 3, h: 10 },
{ n: 7, x: 20, y: 31, w: 4, h: 13 },
{ n: 8, x: 24, y: 37, w: 4, h: 7 },
];
/* 标题区:整格扣出的居中矩形,左右各留 2 格 */
const TITLE = {
text: '关于「甲子」的虚构故事',
emphasis: '甲子',
top: 14,
height: 4,
side: 2,
};
const BIRD_WORDS = { sparrow: '麻雀', magpie: '喜鹊', crow: '乌鸦' };
const grid = Array.from({ length: ROWS }, () =>
Array.from({ length: COLS }, () => ({ ch: '天', cls: 'sky' }))
);
const put = (x, y, ch, cls) => {
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) {
throw new Error(`元素超出网格范围:(${x}, ${y})`);
}
grid[y][x] = { ch, cls };
};
const putRow = (y, x, n, ch, cls) => {
for (let i = 0; i < n; i++) put(x + i, y, ch, cls);
};
for (const { x, y, tone } of clouds) {
const cls = `cloud cloud-${tone}`;
putRow(y, x + 1, 3, '云', cls);
putRow(y + 1, x, 5, '云', cls);
putRow(y + 2, x + 1, 3, '云', cls);
}
sun.forEach(({ y, x, w }, i) => {
const tone = `sun-t${i + 1}`;
for (let dx = 0; dx < w; dx++) put(x + dx, y, '日', `sun ${tone}`);
});
for (const { x, y, kind } of birds) {
const [a, b] = BIRD_WORDS[kind];
put(x, y, a, `bird-${kind}`);
put(x + 1, y, b, `bird-${kind}`);
}
for (const { x, y, h, cls = 'ant' } of antennas) {
for (let i = 0; i < h; i++) put(x, y + i, '线', cls);
}
for (const { n, x, y, w, h } of buildings) {
for (let j = 0; j < h; j++) putRow(y + j, x, w, '楼', `bld bld-${n}`);
}
for (let y = TITLE.top; y < TITLE.top + TITLE.height; y++) {
for (let x = TITLE.side; x < COLS - TITLE.side; x++) {
grid[y][x] = { ch: ' ', cls: 'cut' };
}
}
/* 把同 class 的连续字符合并成一个 span,减少 DOM 节点 */
const html = grid
.map((row) => {
let line = '';
for (let i = 0; i < COLS; ) {
let j = i + 1;
while (j < COLS && row[j].cls === row[i].cls) j++;
const text = row.slice(i, j).map((cell) => cell.ch).join('');
line += `<span class="${row[i].cls}">${text}</span>`;
i = j;
}
return `<div class="line">${line}</div>`;
})
.join('');
const scene = document.querySelector('.scene');
scene.innerHTML = html;
const panel = document.createElement('div');
panel.className = 'title-panel';
panel.style.top = `${TITLE.top}em`;
panel.style.left = `${TITLE.side}em`;
panel.style.width = `${COLS - TITLE.side * 2}em`;
panel.style.height = `${TITLE.height}em`;
const title = document.createElement('h1');
title.className = 'title';
const [before, after] = TITLE.text.split(TITLE.emphasis);
if (after === undefined) {
title.textContent = TITLE.text;
} else {
const em = document.createElement('span');
em.className = 'title-em';
em.textContent = TITLE.emphasis;
title.append(before, em, after);
}
panel.append(title);
scene.append(panel);
})();
+137
View File
@@ -0,0 +1,137 @@
/* 文字画共享渲染引擎
页面脚本调用 TextScene.render({...}) 提供数据即可,示例见 page1.js。
网格坐标:x 0–cols-1,y 0–rows-1;空白格自动填充天空字符。 */
(() => {
const BIRD_WORDS = {
sparrow: '麻雀',
magpie: '喜鹊',
crow: '乌鸦',
};
function render({
cols,
rows,
skyChar = '天',
skyClass = 'sky',
clouds = [],
sun = [],
birds = [],
antennas = [],
buildings = [],
glyphs = [],
title,
container = '.scene',
}) {
if (!cols || !rows) throw new Error('TextScene.render 需要 cols 与 rows');
// skyChar 传空字符串时不铺背景字,空格仍占满整格
const blank = skyChar
? { ch: skyChar, cls: skyClass }
: { ch: ' ', cls: 'void' };
const grid = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => ({ ...blank }))
);
const put = (x, y, ch, cls) => {
if (x < 0 || x >= cols || y < 0 || y >= rows) {
throw new Error(`元素超出网格范围:(${x}, ${y})`);
}
grid[y][x] = { ch, cls };
};
const putRow = (y, x, n, ch, cls) => {
for (let i = 0; i < n; i++) put(x + i, y, ch, cls);
};
for (const { x, y, tone } of clouds) {
const cls = `cloud cloud-${tone}`;
putRow(y, x + 1, 3, '云', cls);
putRow(y + 1, x, 5, '云', cls);
putRow(y + 2, x + 1, 3, '云', cls);
}
sun.forEach(({ y, x, w }, i) => {
const tone = `sun-t${i + 1}`;
for (let dx = 0; dx < w; dx++) put(x + dx, y, '日', `sun ${tone}`);
});
for (const { x, y, kind } of birds) {
const word = BIRD_WORDS[kind];
if (!word) throw new Error(`未知鸟种:${kind}`);
const [a, b] = [...word];
put(x, y, a, `bird-${kind}`);
put(x + 1, y, b, `bird-${kind}`);
}
for (const { x, y, h, cls = 'ant' } of antennas) {
for (let i = 0; i < h; i++) put(x, y + i, '线', cls);
}
for (const { n, x, y, w, h } of buildings) {
for (let j = 0; j < h; j++) putRow(y + j, x, w, '楼', `bld bld-${n}`);
}
for (const { text, x, y, cls = 'glyph', vertical = false } of glyphs) {
[...text].forEach((ch, i) => {
put(vertical ? x : x + i, vertical ? y + i : y, ch, cls);
});
}
if (title) {
for (let y = title.top; y < title.top + title.height; y++) {
for (let x = title.side; x < cols - title.side; x++) {
grid[y][x] = { ch: ' ', cls: 'cut' };
}
}
}
/* 把同 class 的连续字符合并成一个 span,减少 DOM 节点 */
const html = grid
.map((row) => {
let line = '';
for (let i = 0; i < cols; ) {
let j = i + 1;
while (j < cols && row[j].cls === row[i].cls) j++;
const text = row.slice(i, j).map((cell) => cell.ch).join('');
line += `<span class="${row[i].cls}">${text}</span>`;
i = j;
}
return `<div class="line">${line}</div>`;
})
.join('');
const scene = document.querySelector(container);
if (!scene) throw new Error(`未找到容器:${container}`);
scene.innerHTML = html;
if (!title) return;
const panel = document.createElement('div');
panel.className = 'title-panel';
panel.style.top = `${title.top}em`;
panel.style.left = `${title.side}em`;
panel.style.width = `${cols - title.side * 2}em`;
panel.style.height = `${title.height}em`;
const titleEl = document.createElement('h1');
titleEl.className = 'title';
const { text, emphasis } = title;
if (emphasis && text.includes(emphasis)) {
const [before, after] = text.split(emphasis);
const em = document.createElement('span');
em.className = 'title-em';
em.textContent = emphasis;
titleEl.append(before, em, after);
} else {
titleEl.textContent = text;
}
panel.append(titleEl);
scene.append(panel);
}
window.TextScene = { render };
})();