提交web原型存档

This commit is contained in:
2026-02-09 14:02:41 +08:00
parent a77f65b432
commit c509362707
25 changed files with 10094 additions and 3874 deletions
+497
View File
@@ -0,0 +1,497 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>思维侧写</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;500&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
background: #000;
overflow: hidden;
font-family: 'Noto Serif SC', serif;
display: flex;
justify-content: center;
align-items: center;
user-select: none;
}
#stage {
position: relative;
width: 100%;
height: 100%;
}
#analysis-canvas {
display: none;
}
/* 最终图片 */
#final-image-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 420px;
height: 520px;
z-index: 5;
pointer-events: none;
opacity: 0;
}
.real-photo {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 漂浮的句子 */
.clue {
position: absolute;
color: rgba(220, 200, 180, 0.5);
font-size: 12px;
font-weight: 400;
white-space: nowrap;
cursor: crosshair;
transition: color 0.15s, text-shadow 0.15s;
animation: float 4s ease-in-out infinite alternate;
z-index: 50;
letter-spacing: 1px;
}
.clue:hover {
color: rgba(255, 245, 230, 1);
text-shadow: 0 0 15px rgba(255, 200, 150, 0.6);
}
.clue.triggered {
pointer-events: none;
animation: none;
transition: opacity 0.2s ease;
opacity: 0;
}
/* ASCII字符层 */
#ascii-layer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
pointer-events: none;
}
/* 单个ASCII字符 */
.char {
position: absolute;
font-family: 'Noto Serif SC', serif;
pointer-events: none;
opacity: 0;
text-align: center;
transition: opacity 0.2s ease;
line-height: 1;
}
.char.placed {
opacity: 1;
}
.char.flying {
transition: left 0.35s cubic-bezier(0.22, 1, 0.36, 1),
top 0.35s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.15s ease;
}
@keyframes float {
0% { transform: translateY(0) rotate(-0.5deg); }
100% { transform: translateY(-6px) rotate(0.5deg); }
}
/* 进度 */
#progress {
position: fixed;
bottom: 30px;
right: 30px;
color: rgba(255,255,255,0.3);
font-size: 12px;
font-family: 'Courier New', monospace;
z-index: 200;
}
/* 提示 */
#hint {
position: fixed;
top: 30px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.25);
font-size: 12px;
letter-spacing: 5px;
transition: opacity 1s;
}
#hint.hidden { opacity: 0; }
/* 结局文字 */
#reveal-text {
position: absolute;
bottom: 5%;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 240, 220, 0);
font-size: 14px;
letter-spacing: 12px;
z-index: 100;
transition: color 2s ease 0.5s;
}
#reveal-text.show {
color: rgba(255, 240, 220, 0.85);
}
</style>
</head>
<body>
<div id="stage">
<canvas id="analysis-canvas"></canvas>
<div id="ascii-layer"></div>
<div id="final-image-container">
<img id="source-image"
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=800&auto=format&fit=crop"
crossorigin="anonymous"
class="real-photo" alt="">
</div>
<div id="reveal-text">就 是 她</div>
</div>
<div id="progress">0%</div>
<div id="hint">移动鼠标 · 拼凑真相</div>
<script>
// 80句
const vocab = [
"黑色风衣女人", "长发遮半边脸", "雨夜独自站", "高跟鞋声响",
"淡淡的烟味", "红唇微颤抖", "沉默的背影", "躲闪的眼神",
"昨晚十点整", "最后的渡轮", "她在说谎", "左手的戒指",
"指尖有泥土", "侧脸的轮廓", "电话突然断", "车票的日期",
"颤抖的双手", "欲言又止", "匆忙离现场", "丝巾遮脖颈",
"眼角的泪痕", "紧握的拳头", "深夜的来访", "无法解释的",
"那晚她在场", "目击者证词", "最后见到她", "消失的证据",
"她知道真相", "不在场证明", "香水的味道", "雨中的身影",
"码头的尽头", "转身的瞬间", "路灯下剪影", "低语的声音",
"犹豫的脚步", "紧锁的眉头", "冰冷的手指", "破碎的谎言",
"隐藏的秘密", "午夜的电话", "模糊的记忆", "真相的碎片",
"无声的控诉", "逃离的背影", "最后的晚餐", "致命的证据",
"她就是凶手", "就是她", "那个女人", "雨夜的秘密",
"无人知晓的", "深藏的恐惧", "午夜的访客", "不可告人的",
"最后的线索", "关键的证人", "遗失的记忆", "隐藏的伤痕",
"沉默的真相", "破碎的承诺", "无法回头的", "命运的交点",
"时间的裂缝", "被遗忘的夜", "最后的告白", "无声的呐喊",
"迷失的方向", "黑暗中的影", "月光下的她", "寂静的街道",
"最后的机会", "不能说的秘", "被掩盖的罪", "午夜的约定"
];
// 配置
const IMG_WIDTH = 420;
const IMG_HEIGHT = 520;
const CELL_SIZE = 10; // 更小的网格 = 更密集
const totalClues = 300; // 120个句子
let collectedCount = 0;
let isRevealed = false;
let gridData = [];
let gridIndex = 0;
const stage = document.getElementById('stage');
const asciiLayer = document.getElementById('ascii-layer');
const finalImageContainer = document.getElementById('final-image-container');
const sourceImage = document.getElementById('source-image');
const canvas = document.getElementById('analysis-canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const progress = document.getElementById('progress');
const hint = document.getElementById('hint');
const revealText = document.getElementById('reveal-text');
// 分析图像
function analyzeImage() {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
canvas.width = IMG_WIDTH;
canvas.height = IMG_HEIGHT;
// 强对比度 + 灰度
ctx.filter = 'contrast(1.8) brightness(1.0) saturate(0)';
ctx.drawImage(img, 0, 0, IMG_WIDTH, IMG_HEIGHT);
ctx.filter = 'none';
const imageData = ctx.getImageData(0, 0, IMG_WIDTH, IMG_HEIGHT);
const data = imageData.data;
// 构建亮度图并找极值
let brightnessMap = [];
let minB = 255, maxB = 0;
for (let y = 0; y < IMG_HEIGHT; y++) {
brightnessMap[y] = [];
for (let x = 0; x < IMG_WIDTH; x++) {
const i = (y * IMG_WIDTH + x) * 4;
const b = data[i] * 0.299 + data[i+1] * 0.587 + data[i+2] * 0.114;
brightnessMap[y][x] = b;
if (b < minB) minB = b;
if (b > maxB) maxB = b;
}
}
// 归一化
const range = maxB - minB || 1;
for (let y = 0; y < IMG_HEIGHT; y++) {
for (let x = 0; x < IMG_WIDTH; x++) {
brightnessMap[y][x] = ((brightnessMap[y][x] - minB) / range) * 255;
}
}
// 生成网格数据
gridData = [];
const cols = Math.floor(IMG_WIDTH / CELL_SIZE);
const rows = Math.floor(IMG_HEIGHT / CELL_SIZE);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const cx = col * CELL_SIZE + CELL_SIZE / 2;
const cy = row * CELL_SIZE + CELL_SIZE / 2;
// 采样3x3区域取平均,更平滑
let sum = 0, count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const sy = Math.floor(cy) + dy;
const sx = Math.floor(cx) + dx;
if (sy >= 0 && sy < IMG_HEIGHT && sx >= 0 && sx < IMG_WIDTH) {
sum += brightnessMap[sy][sx];
count++;
}
}
}
const brightness = sum / count;
const darkness = 255 - brightness;
// 更低的阈值,捕获更多细节
if (darkness > 15) {
// 非线性映射,增强对比
const d = darkness / 255;
const dd = Math.pow(d, 0.8); // 提升暗部
const size = 5 + dd * 9; // 5-14px
const alpha = 0.08 + dd * 0.9; // 0.08-0.98
gridData.push({
x: cx,
y: cy,
size: size,
alpha: alpha,
darkness: darkness
});
}
}
}
// 按暗度排序
gridData.sort((a, b) => b.darkness - a.darkness);
// 分块打乱
const n = gridData.length;
const c1 = shuffleArray(gridData.slice(0, n * 0.2));
const c2 = shuffleArray(gridData.slice(n * 0.2, n * 0.4));
const c3 = shuffleArray(gridData.slice(n * 0.4, n * 0.6));
const c4 = shuffleArray(gridData.slice(n * 0.6, n * 0.8));
const c5 = shuffleArray(gridData.slice(n * 0.8));
gridData = [...c1, ...c2, ...c3, ...c4, ...c5];
console.log(`生成了 ${gridData.length} 个网格位置`);
resolve();
};
img.onerror = () => {
generateFallbackGrid();
resolve();
};
img.src = sourceImage.src;
});
}
function generateFallbackGrid() {
gridData = [];
const cols = Math.floor(IMG_WIDTH / CELL_SIZE);
const rows = Math.floor(IMG_HEIGHT / CELL_SIZE);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const cx = col * CELL_SIZE + CELL_SIZE / 2;
const cy = row * CELL_SIZE + CELL_SIZE / 2;
const dx = (cx - IMG_WIDTH/2) / (IMG_WIDTH * 0.4);
const dy = (cy - IMG_HEIGHT/2) / (IMG_HEIGHT * 0.48);
if (dx*dx + dy*dy < 1) {
const d = 1 - Math.sqrt(dx*dx + dy*dy);
gridData.push({
x: cx, y: cy,
size: 6 + d * 8,
alpha: 0.2 + d * 0.6,
darkness: d * 200
});
}
}
}
gridData = shuffleArray(gridData);
}
function shuffleArray(array) {
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function getNextGridCells(count) {
const cells = [];
for (let i = 0; i < count && gridIndex < gridData.length; i++) {
cells.push(gridData[gridIndex++]);
}
return cells;
}
async function init() {
await analyzeImage();
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
asciiLayer.style.width = IMG_WIDTH + 'px';
asciiLayer.style.height = IMG_HEIGHT + 'px';
// 创建更多句子
for (let i = 0; i < totalClues; i++) {
const el = document.createElement('div');
el.classList.add('clue');
const text = vocab[i % vocab.length];
el.innerText = text;
el.dataset.text = text;
const angle = (i / totalClues) * Math.PI * 2 + Math.random() * 0.3;
const dist = 280 + Math.random() * 280;
const startX = centerX + Math.cos(angle) * dist;
const startY = centerY + Math.sin(angle) * dist;
el.style.left = startX + 'px';
el.style.top = startY + 'px';
el.dataset.startX = startX;
el.dataset.startY = startY;
el.style.fontSize = (11 + Math.random() * 3) + 'px';
el.style.animationDelay = (Math.random() * 3) + 's';
el.addEventListener('mouseenter', handleHover);
stage.appendChild(el);
}
}
function handleHover(e) {
const el = e.target;
if (el.classList.contains('triggered') || isRevealed) return;
el.classList.add('triggered');
const text = el.dataset.text;
const startX = parseFloat(el.dataset.startX);
const startY = parseFloat(el.dataset.startY);
const imgRect = finalImageContainer.getBoundingClientRect();
const imgCenterX = imgRect.left + imgRect.width / 2;
const imgCenterY = imgRect.top + imgRect.height / 2;
const chars = text.split('');
const cells = getNextGridCells(chars.length);
chars.forEach((char, i) => {
if (i >= cells.length) return;
const cell = cells[i];
const charEl = document.createElement('div');
charEl.classList.add('char', 'flying');
charEl.innerText = char;
const startPosX = startX - imgCenterX + IMG_WIDTH/2;
const startPosY = startY - imgCenterY + IMG_HEIGHT/2;
charEl.style.left = startPosX + 'px';
charEl.style.top = startPosY + 'px';
charEl.style.fontSize = cell.size + 'px';
charEl.style.color = `rgba(255, 255, 255, ${cell.alpha})`;
charEl.style.width = CELL_SIZE + 'px';
charEl.style.height = CELL_SIZE + 'px';
charEl.style.lineHeight = CELL_SIZE + 'px';
asciiLayer.appendChild(charEl);
setTimeout(() => {
charEl.style.left = cell.x + 'px';
charEl.style.top = cell.y + 'px';
charEl.classList.add('placed');
}, 5 + i * 10);
});
collectedCount++;
const pct = Math.round((gridIndex / gridData.length) * 100);
progress.textContent = pct + '%';
if (collectedCount > 5) hint.classList.add('hidden');
// 90% 格子填满触发结局
if (gridIndex >= gridData.length * 0.9) {
revealIdentity();
}
}
function revealIdentity() {
if (isRevealed) return;
isRevealed = true;
const allChars = document.querySelectorAll('.char.placed');
allChars.forEach((el, i) => {
setTimeout(() => {
el.style.transition = 'opacity 1.5s ease';
el.style.opacity = '0.03';
}, i * 0.3);
});
setTimeout(() => {
finalImageContainer.style.transition = 'opacity 2s ease';
finalImageContainer.style.opacity = 1;
}, 100);
setTimeout(() => {
revealText.classList.add('show');
}, 800);
document.querySelectorAll('.clue:not(.triggered)').forEach(el => {
el.style.transition = 'opacity 0.3s';
el.style.opacity = 0;
});
progress.style.transition = 'opacity 0.5s';
progress.style.opacity = 0;
}
window.onload = init;
</script>
</body>
</html>