提交web原型存档
This commit is contained in:
+497
@@ -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>
|
||||
Reference in New Issue
Block a user