<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>HTML5切积木益智游戏</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<canvas id="c"></canvas>
<div class="hud">
<div class="hud__score">
<div class="score-lbl"></div>
<div class="cube-count-lbl"></div>
</div>
<div class="pause-btn"><div></div></div>
<div class="slowmo">
<div class="slowmo__bar"></div>
</div>
</div>
<!-- 开始部分 -->
<div class="menus">
<div class="menu menu--main">
<h1>切块</h1>
<button type="button" class="play-normal-btn">开始游戏</button>
<button type="button" class="play-casual-btn">休闲模式</button>
</div>
<div class="menu menu--pause">
<h1>暂停</h1>
<button type="button" class="resume-btn">继续游戏</button>
<button type="button" class="menu-btn--pause">主菜单</button>
</div>
<div class="menu menu--score">
<h1>游戏结束</h1>
<h2>分数:</h2>
<div class="final-score-lbl"></div>
<div class="high-score-lbl"></div>
<button type="button" class="play-again-btn">再玩一次</button>
<button type="button" class="menu-btn--score">主菜单</button>
</div>
</div>
<script src="./index.js"></script>
</body>
</html>
index.html
style.css
index.js
现在支持上传本地图片了!
body {
margin: 0;
background-color: #000;
background-image: radial-gradient(ellipse at top, #335476 0.0%, #31506e 11.1%, #304b67 22.2%, #2f4760 33.3%, #2d4359 44.4%, #2c3f51 55.6%, #2a3a4a 66.7%, #293643 77.8%, #28323d 88.9%, #262e36 100.0%);
height: 100vh;
overflow: hidden;
/* 字体系列:等宽字体; */
font-weight: bold;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.75);
}
#c {
display: block;
touch-action: none;
transform: translateZ(0);
}
.hud__score,
.pause-btn {
position: fixed;
font-size: calc(14px + 2vw + 1vh);
}
.hud__score {
top: 0.65em;
left: 0.65em;
pointer-events: none;
user-select: none;
}
.cube-count-lbl {
font-size: 0.46em;
}
.pause-btn {
position: fixed;
top: 0;
right: 0;
padding: 0.8em 0.65em;
}
.pause-btn > div {
position: relative;
width: 0.8em;
height: 0.8em;
opacity: 0.75;
}
.pause-btn > div::before,
.pause-btn > div::after {
content: '';
display: block;
width: 34%;
height: 100%;
position: absolute;
background-color: #fff;
}
.pause-btn > div::after {
right: 0;
}
.slowmo {
position: fixed;
bottom: 0;
width: 100%;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s;
will-change: opacity;
}
.slowmo::before {
content: 'SLOW-MO';
display: block;
font-size: calc(8px + 1vw + 0.5vh);
margin-left: 0.5em;
margin-bottom: 8px;
}
.slowmo::after {
content: '';
display: block;
position: fixed;
bottom: 0;
width: 100%;
height: 1.5vh;
background-color: rgba(0, 0, 0, 0.25);
z-index: -1;
}
.slowmo__bar {
height: 1.5vh;
background-color: rgba(255, 255, 255, 0.75);
transform-origin: 0 0;
}
/*/////////////////////
// 菜单 //
/////////////////////*/
.menus::before {
content: '';
pointer-events: none;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: #000;
opacity: 0;
transition: opacity 0.2s;
transition-timing-function: ease-in;
}
.menus.has-active::before {
opacity: 0.08;
transition-duration: 0.4s;
transition-timing-function: ease-out;
}
.menus.interactive-mode::before {
opacity: 0.02;
}
/* 菜单容器区域 */
.menu {
pointer-events: none;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
user-select: none;
text-align: center;
color: rgba(255, 255, 255, 0.9);
opacity: 0;
visibility: hidden;
transform: translateY(30px);
transition-property: opacity, visibility, transform;
transition-duration: 0.2s;
transition-timing-function: ease-in;
}
.menu.active {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition-duration: 0.4s;
transition-timing-function: ease-out;
}
.menus.interactive-mode .menu.active {
opacity: 0.6;
}
.menus:not(.interactive-mode) .menu.active > * {
pointer-events: auto;
}
/* 常见的菜单元素 */
h1 {
font-size: 4rem;
line-height: 0.95;
text-align: center;
font-weight: bold;
margin: 0 0.65em 1em;
}
h2 {
font-size: 1.2rem;
line-height: 1;
text-align: center;
font-weight: bold;
margin: -1em 0.65em 1em;
}
.final-score-lbl {
font-size: 5rem;
margin: -0.2em 0 0;
}
.high-score-lbl {
font-size: 1.2rem;
margin: 0 0 2.5em;
}
button {
display: block;
position: relative;
width: 200px;
padding: 12px 20px;
background: transparent;
border: none;
outline: none;
user-select: none;
font-family: monospace;
font-weight: bold;
font-size: 1.4rem;
color: #fff;
opacity: 0.75;
transition: opacity 0.3s;
}
button::before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(255, 255, 255, 0.15);
transform: scale(0, 0);
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
}
button:active {
opacity: 1;
}
button:active::before {
transform: scale(1, 1);
opacity: 1;
}
.credits {
position: fixed;
width: 100%;
left: 0;
bottom: 20px;
}
a {
color: white;
}
/* 在大屏幕上启用悬停状态 */
@media (min-width: 1025px) {
button:hover {
opacity: 1;
}
button:hover::before {
transform: scale(1, 1);
opacity: 1;
}
}
// globalConfig.js
// ============================================================================
// ============================================================================
// 提供整个程序使用的全局变量。
// 这其中的大部分内容应该是配置。
// 整个游戏引擎的时间倍速乘数。
// 后来江枫团队决定优化本游戏并达到稳定且很好的运行。
// 江枫在2025.04.2日开始优化汉化版 2025.08.18停止汉化
// 2025.7.21 22.52 本次更新
let gameSpeed = 1;
// 颜色
const BLUE = { r: 0x67, g: 0xd7, b: 0xf0 };
const GREEN = { r: 0xa6, g: 0xe0, b: 0x2c };
const PINK = { r: 0xfa, g: 0x24, b: 0x73 };
const ORANGE = { r: 0xfe, g: 0x95, b: 0x22 };
const allColors = [BLUE, GREEN, PINK, ORANGE];
// 游戏玩法初始化
const getSpawnDelay = () => {
const spawnDelayMax = 1400;
const spawnDelayMin = 550;
const spawnDelay = spawnDelayMax - state.game.cubeCount * 3.1;
return Math.max(spawnDelay, spawnDelayMin);
}
const doubleStrongEnableScore = 2000;
// 在发射某个方块之前必须粉碎的立方体数量。
const slowmoThreshold = 10;
const strongThreshold = 25;
const spinnerThreshold = 25;
// 交互状态
let pointerIsDown = false;
// 主要指针在屏幕坐标中的上一次已知位置。
let pointerScreen = { x: 0, y: 0 };
// 与 pointerScreen 相同,但在 rAF(requestAnimationFrame)中转换为场景坐标
let pointerScene = { x: 0, y: 0 };
// 指针在被计为‘滑动’之前的最小速度。
const minPointerSpeed = 60;
// 点击速度会影响目标被击中后的方向。这个数值会减弱游戏数度。
const hitDampening = 0.1;
// 篮板接收阴影,并且是实体在 Z 轴上最远的负方向位置。
const backboardZ = -400;
const shadowColor = '#262e36';
// 必须到目标值才能被粉碎。
const airDrag = 0.022;
const gravity = 0.3;
// 江枫配置
const sparkColor = 'rgba(170,221,255,.9)';
const sparkThickness = 2.2;
const airDragSpark = 0.1;
// 跟踪指针位置以显示轨迹
const touchTrailColor = 'rgba(170,221,255,.62)';
const touchTrailThickness = 7;
const touchPointLife = 120;
const touchPoints = [];
// 游戏内目标的大小。这会影响渲染的大小和命中区域。
const targetRadius = 40;
const targetHitRadius = 50;
const makeTargetGlueColor = target => {
// 计算目标对象的透明度(alpha 值),公式是 (目标生命值 - 1)除以(目标最大生命值 - 1);
// rgba(170,221,255,${alpha.toFixed(3)})
// 返回一个 rgba 颜色值,其中红色、绿色、蓝色的值分别是 170、221、255,透明度(alpha)取 alpha 变量的值并保留三位小数。;
return 'rgb(170,221,255)';
};
// 目标碎片的大小
const fragRadius = targetRadius / 3;
// 在 setup.js 和 interaction.js 中需要游戏画布元素
const canvas = document.querySelector('#c');
// 三维摄像机配置
// 适配影响透视效果
const cameraDistance = 900;
// 不影响透视效果
const sceneScale = 1;
// 在这个距离范围内,离摄像机过近的物体会逐渐变为透明状态。
// const cameraFadeStartZ = 0.8*cameraDistance - 6*targetRadius; 代码解释:
// 定义一个名为 “cameraFadeStartZ” 的常量,其值等于摄像机距离(cameraDistance)乘以 0.8,再减去目标半径(targetRadius)的 6 倍。这个公式通常用于计算摄像机淡出效果的起始位置,当物体在这个范围内时会逐渐变为透明。
const cameraFadeStartZ = 0.45 * cameraDistance;
const cameraFadeEndZ = 0.65 * cameraDistance;
const cameraFadeRange = cameraFadeEndZ - cameraFadeStartZ;
// 全局变量,用于累积每帧中的所有顶点和多边形。
const allVertices = [];
const allPolys = [];
const allShadowVertices = [];
const allShadowPolys = [];
// state.js
// ============================================================================
// ============================================================================
///////////
// 枚举 //
///////////
// 全局状态
const GAME_MODE_RANKED = Symbol('GAME_MODE_RANKED');
const GAME_MODE_CASUAL = Symbol('GAME_MODE_CASUAL');
// 可用菜单
const MENU_MAIN = Symbol('MENU_MAIN');
const MENU_PAUSE = Symbol('MENU_PAUSE');
const MENU_SCORE = Symbol('MENU_SCORE');
//////////////////
// 全局状态 //
//////////////////
const state = {
game: {
mode: GAME_MODE_RANKED,
// 当前游戏的运行时间。
time: 0,
// Player score.
score: 0,
// 游戏中被打碎的立方体总数。
cubeCount: 0
},
menus: {
// 设置为 null 以隐藏所有菜单
active: MENU_MAIN
}
};
////////////////////////////
// 全局状态选择器 //
////////////////////////////
const isInGame = () => !state.menus.active;
const isMenuVisible = () => !!state.menus.active;
const isCasualGame = () => state.game.mode === GAME_MODE_CASUAL;
const isPaused = () => state.menus.active === MENU_PAUSE;
///////////////////
// 本地存储 //
///////////////////
const highScoreKey = '__menja__highScore';
const getHighScore = () => {
const raw = localStorage.getItem(highScoreKey);
return raw ? parseInt(raw, 10) : 0;
};
const setHighScore = score => localStorage.setItem(highScoreKey, String(score))
// utils.js
// ============================================================================
// ============================================================================
const invariant = (condition, message) => {
if (!condition) throw new Error(message);
};
/////////
// DOM //
/////////
const $ = selector => document.querySelector(selector);
const handleClick = (element, handler) => element.addEventListener('click', handler);
const handlePointerDown = (element, handler) => {
element.addEventListener('touchstart', handler);
element.addEventListener('mousedown', handler);
};
////////////////////////
// 格式化辅助工具 //
////////////////////////
// Converts a number into a formatted string with thousand separators.
const formatNumber = num => num.toLocaleString();
////////////////////
// 数学常量 //
////////////////////
const PI = Math.PI;
const TAU = Math.PI * 2;
const ETA = Math.PI * 0.5;
//////////////////
// 数学辅助工具 //
//////////////////
// 将数值限制在最小值和最大值之间(包括边界值)
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
// 通过特定的比例在线性范围内在数字 a 和 b 之间进行插值。
// mix >= 0 && mix <= 1
const lerp = (a, b, mix) => (b - a) * mix + a;
////////////////////
// 伪随机辅助 //
////////////////////
// 生成一个在最小值(包含)和最大值(不包含)之间的随机数
const random = (min, max) => Math.random() * (max - min) + min;
// 生成一个介于最小值和最大值之间的随机整数,可能包含这两个边界值
const randomInt = (min, max) => ((Math.random() * (max - min + 1)) | 0) + min;
// 从数组中返回一个随机元素
const pickOne = arr => arr[Math.random() * arr.length | 0];
///////////////////
// 颜色辅助工具 //
///////////////////
// 将一个 { r, g, b } 颜色对象转换为一个6位的十六进制代码。
const colorToHex = color => {
return '#' +
(color.r | 0).toString(16).padStart(2, '0') +
(color.g | 0).toString(16).padStart(2, '0') +
(color.b | 0).toString(16).padStart(2, '0');
};
// 在 { r, g, b } 颜色对象上进行操作。
// 返回字符串形式的十六进制代码
// 亮度 必须在 0 到 1 之间。0 表示纯黑色,1 表示纯白色。
const shadeColor = (color, lightness) => {
let other, mix;
if (lightness < 0.5) {
other = 0;
mix = 1 - (lightness * 2);
} else {
other = 255;
mix = lightness * 2 - 1;
}
return '#' +
(lerp(color.r, other, mix) | 0).toString(16).padStart(2, '0') +
(lerp(color.g, other, mix) | 0).toString(16).padStart(2, '0') +
(lerp(color.b, other, mix) | 0).toString(16).padStart(2, '0');
};
////////////////////
// 时间辅助工具 //
////////////////////
const _allCooldowns = [];
const makeCooldown = (rechargeTime, units = 1) => {
let timeRemaining = 0;
let lastTime = 0;
const initialOptions = { rechargeTime, units };
const updateTime = () => {
const now = state.game.time;
// 如果时间出现倒退的情况,那么就重新设置剩余的时间。
if (now < lastTime) {
timeRemaining = 0;
} else {
// 更新方块...
timeRemaining -= now - lastTime;
if (timeRemaining < 0) timeRemaining = 0;
}
lastTime = now;
};
const canUse = () => {
updateTime();
return timeRemaining <= (rechargeTime * (units - 1));
};
const cooldown = {
canUse,
useIfAble() {
const usable = canUse();
if (usable) timeRemaining += rechargeTime;
return usable;
},
mutate(options) {
if (options.rechargeTime) {
// 应用充值时间增量,以便更改立即生效
timeRemaining -= rechargeTime - options.rechargeTime;
if (timeRemaining < 0) timeRemaining = 0;
rechargeTime = options.rechargeTime;
}
if (options.units) units = options.units;
},
reset() {
timeRemaining = 0;
lastTime = 0;
this.mutate(initialOptions);
}
};
_allCooldowns.push(cooldown);
return cooldown;
};
const resetAllCooldowns = () => _allCooldowns.forEach(cooldown => cooldown.reset());
const makeSpawner = ({ chance, cooldownPerSpawn, maxSpawns }) => {
const cooldown = makeCooldown(cooldownPerSpawn, maxSpawns);
return {
shouldSpawn() {
return Math.random() <= chance && cooldown.useIfAble();
},
mutate(options) {
if (options.chance) chance = options.chance;
cooldown.mutate({
rechargeTime: options.cooldownPerSpawn,
units: options.maxSpawns
});
}
};
};
////////////////////
// 向量辅助工具 //
////////////////////
const normalize = v => {
const mag = Math.hypot(v.x, v.y, v.z);
return {
x: v.x / mag,
y: v.y / mag,
z: v.z / mag
};
}
// 柯里化数学助手
const add = a => b => a + b;
// Curried vector helpers
const scaleVector = scale => vector => {
vector.x *= scale;
vector.y *= scale;
vector.z *= scale;
};
////////////////
// 3D辅助 //
////////////////
// 克隆数组以及所有顶点
function cloneVertices(vertices) {
return vertices.map(v => ({ x: v.x, y: v.y, z: v.z }));
}
// 将一个数组中的顶点数据复制到另一个数组中
// 数组的长度必须相同
function copyVerticesTo(arr1, arr2) {
const len = arr1.length;
for (let i = 0; i < len; i++) {
const v1 = arr1[i];
const v2 = arr2[i];
v2.x = v1.x;
v2.y = v1.y;
v2.z = v1.z;
}
}
// 计算三角形的中点
// 改变给定poly的middle属性
function computeTriMiddle(poly) {
const v = poly.vertices;
poly.middle.x = (v[0].x + v[1].x + v[2].x) / 3;
poly.middle.y = (v[0].y + v[1].y + v[2].y) / 3;
poly.middle.z = (v[0].z + v[1].z + v[2].z) / 3;
}
// 计算四边形的中点
// 改变给定poly的middle属性
function computeQuadMiddle(poly) {
const v = poly.vertices;
poly.middle.x = (v[0].x + v[1].x + v[2].x + v[3].x) / 4;
poly.middle.y = (v[0].y + v[1].y + v[2].y + v[3].y) / 4;
poly.middle.z = (v[0].z + v[1].z + v[2].z + v[3].z) / 4;
}
function computePolyMiddle(poly) {
if (poly.vertices.length === 3) {
computeTriMiddle(poly);
} else {
computeQuadMiddle(poly);
}
}
// 计算任意多边形(三角形或四边形)的中点到摄像机的距离
// 设置给定poly的depth属性”
// 还会触发中点计算,这个计算过程会改变poly对象的middle属性。
function computePolyDepth(poly) {
computePolyMiddle(poly);
const dX = poly.middle.x;
const dY = poly.middle.y;
const dZ = poly.middle.z - cameraDistance;
poly.depth = Math.hypot(dX, dY, dZ);
}
// 计算任意多边形的法向量。使用归一化的向量叉乘。
// 改变给定poly的normalName属性”
function computePolyNormal(poly, normalName) {
// Store quick refs to vertices
const v1 = poly.vertices[0];
const v2 = poly.vertices[1];
const v3 = poly.vertices[2];
// 按照绕序计算顶点的差值
const ax = v1.x - v2.x;
const ay = v1.y - v2.y;
const az = v1.z - v2.z;
const bx = v1.x - v3.x;
const by = v1.y - v3.y;
const bz = v1.z - v3.z;
// Cross product 向量积
const nx = ay * bz - az * by;
const ny = az * bx - ax * bz;
const nz = ax * by - ay * bx;
// 计算法线的大小并进行归一化
const mag = Math.hypot(nx, ny, nz);
const polyNormal = poly[normalName];
polyNormal.x = nx / mag;
polyNormal.y = ny / mag;
polyNormal.z = nz / mag;
}
// 对所有给定的顶点应用平移/旋转/缩放操作
// 如果vertices(顶点数组)和target(目标数组)是同一个数组,那么vertices中的顶点数据将会在原地(即在原数组中)被修改。
// 如果 vertices 和 target 是不同的数组,那么 vertices 不会被修改,相反,会修改另一个东西
// 从vertices中转换得到的值将被写入target数组。
function transformVertices(vertices, target, tX, tY, tZ, rX, rY, rZ, sX, sY, sZ) {
// 矩阵乘法中的常数项只需要计算一次,即可用于所有顶点。
const sinX = Math.sin(rX);
const cosX = Math.cos(rX);
const sinY = Math.sin(rY);
const cosY = Math.cos(rY);
const sinZ = Math.sin(rZ);
const cosZ = Math.cos(rZ);
// 使用 map() 一样使用 forEach(),但使用一个(可复用的)目标数组
vertices.forEach((v, i) => {
const targetVertex = target[i];
// X axis rotation X轴旋转
const x1 = v.x;
const y1 = v.z * sinX + v.y * cosX;
const z1 = v.z * cosX - v.y * sinX;
// Y axis rotation Y轴旋转
const x2 = x1 * cosY - z1 * sinY;
const y2 = y1;
const z2 = x1 * sinY + z1 * cosY;
// Z axis rotation X轴旋转
const x3 = x2 * cosZ - y2 * sinZ;
const y3 = x2 * sinZ + y2 * cosZ;
const z3 = z2;
// 缩放、平移并设置变换。
targetVertex.x = x3 * sX + tX;
targetVertex.y = y3 * sY + tY;
targetVertex.z = z3 * sZ + tZ;
});
}
// 3D projection on a single vertex. (字面意思)
// 直接修改顶点
const projectVertex = v => {
const focalLength = cameraDistance * sceneScale;
const depth = focalLength / (cameraDistance - v.z);
v.x = v.x * depth;
v.y = v.y * depth;
};
// 3D projection on a single vertex. (字面意思)
// 修改次要目标顶点的数据
const projectVertexTo = (v, target) => {
const focalLength = cameraDistance * sceneScale;
const depth = focalLength / (cameraDistance - v.z);
target.x = v.x * depth;
target.y = v.y * depth;
};
// PERF.js
// ============================================================================
// ============================================================================
// 空操作哑函数
// 我将这些函数用于特殊构建版本,以实现自定义性能分析
const PERF_START = () => { };
const PERF_END = () => { };
const PERF_UPDATE = () => { };
// 3dModels.js
// ============================================================================
// ============================================================================
// 定义一次模型。原点是模型的中心
// 一个简单的立方体,8 个顶点,6 个四边形
// 默认边长为 2 个单位,可以通过 `scale` 来调整
function makeCubeModel({ scale = 1 }) {
return {
vertices: [
// top
{ x: -scale, y: -scale, z: scale },
{ x: scale, y: -scale, z: scale },
{ x: scale, y: scale, z: scale },
{ x: -scale, y: scale, z: scale },
// bottom
{ x: -scale, y: -scale, z: -scale },
{ x: scale, y: -scale, z: -scale },
{ x: scale, y: scale, z: -scale },
{ x: -scale, y: scale, z: -scale }
],
polys: [
// z = 1
{ vIndexes: [0, 1, 2, 3] },
// z = -1
{ vIndexes: [7, 6, 5, 4] },
// y = 1
{ vIndexes: [3, 2, 6, 7] },
// y = -1
{ vIndexes: [4, 5, 1, 0] },
// x = 1
{ vIndexes: [5, 6, 2, 1] },
// x = -1
{ vIndexes: [0, 3, 7, 4] }
]
};
}
// 优化程度不足——生成了大量重复的顶点数据
function makeRecursiveCubeModel({ recursionLevel, splitFn, color, scale = 1 }) {
const getScaleAtLevel = level => 1 / (3 ** level);
// 我们可以手动建模第 0 级。它只是一个单一的、居中的立方体
let cubeOrigins = [{ x: 0, y: 0, z: 0 }];
// 递归地用较小的立方体替换立方体
for (let i = 1; i <= recursionLevel; i++) {
const scale = getScaleAtLevel(i) * 2;
const cubeOrigins2 = [];
cubeOrigins.forEach(origin => {
cubeOrigins2.push(...splitFn(origin, scale));
});
cubeOrigins = cubeOrigins2;
}
const finalModel = { vertices: [], polys: [] };
// 生成一个立方体模型并对其进行缩放
const cubeModel = makeCubeModel({ scale: 1 });
cubeModel.vertices.forEach(scaleVector(getScaleAtLevel(recursionLevel)));
// 计算原点的 x、y 或 z 值将到达的最大距离
// 与 `Math.max(...cubeOrigins.map(o => o.x))` 得出的结果相同,但速度要快得多
const maxComponent = getScaleAtLevel(recursionLevel) * (3 ** recursionLevel - 1);
// 在每个原点放置立方体几何体
cubeOrigins.forEach((origin, cubeIndex) => {
// 要计算遮挡(阴影),找出最大的原点分
// 找出原点分量的绝对值最大的,并将其归一化到 `maxComponent` 相对大小
const occlusion = Math.max(
Math.abs(origin.x),
Math.abs(origin.y),
Math.abs(origin.z)
) / maxComponent;
// 在较低的迭代次数下,遮挡效果看起来更好一些,可以稍微调亮
const occlusionLighter = recursionLevel > 2
? occlusion
: (occlusion + 0.8) / 1.8;
// 克隆、将顶点平移到原点并应用缩放
finalModel.vertices.push(
...cubeModel.vertices.map(v => ({
x: (v.x + origin.x) * scale,
y: (v.y + origin.y) * scale,
z: (v.z + origin.z) * scale
}))
);
// 克隆多边形,移动引用的顶点索引,并计算颜色
finalModel.polys.push(
...cubeModel.polys.map(poly => ({
vIndexes: poly.vIndexes.map(add(cubeIndex * 8))
}))
);
});
return finalModel;
}
// o: Vector3D - 立方体原点(中心)的位置
// s: Vector3D - 确定门格尔海绵的大小
function mengerSpongeSplit(o, s) {
return [
// 顶部
{ x: o.x + s, y: o.y - s, z: o.z + s },
{ x: o.x + s, y: o.y - s, z: o.z + 0 },
{ x: o.x + s, y: o.y - s, z: o.z - s },
{ x: o.x + 0, y: o.y - s, z: o.z + s },
{ x: o.x + 0, y: o.y - s, z: o.z - s },
{ x: o.x - s, y: o.y - s, z: o.z + s },
{ x: o.x - s, y: o.y - s, z: o.z + 0 },
{ x: o.x - s, y: o.y - s, z: o.z - s },
// 底部
{ x: o.x + s, y: o.y + s, z: o.z + s },
{ x: o.x + s, y: o.y + s, z: o.z + 0 },
{ x: o.x + s, y: o.y + s, z: o.z - s },
{ x: o.x + 0, y: o.y + s, z: o.z + s },
{ x: o.x + 0, y: o.y + s, z: o.z - s },
{ x: o.x - s, y: o.y + s, z: o.z + s },
{ x: o.x - s, y: o.y + s, z: o.z + 0 },
{ x: o.x - s, y: o.y + s, z: o.z - s },
// 中间
{ x: o.x + s, y: o.y + 0, z: o.z + s },
{ x: o.x + s, y: o.y + 0, z: o.z - s },
{ x: o.x - s, y: o.y + 0, z: o.z + s },
{ x: o.x - s, y: o.y + 0, z: o.z - s }
];
}
// 用于通过在阈值范围内合并重复顶点来优化模型的助手函数
// 以及删除所有共享相同顶点的多边形
// 直接修改模型
function optimizeModel(model, threshold = 0.0001) {
const { vertices, polys } = model;
const compareVertices = (v1, v2) => (
Math.abs(v1.x - v2.x) < threshold &&
Math.abs(v1.y - v2.y) < threshold &&
Math.abs(v1.z - v2.z) < threshold
);
const comparePolys = (p1, p2) => {
const v1 = p1.vIndexes;
const v2 = p2.vIndexes;
return (
(
v1[0] === v2[0] ||
v1[0] === v2[1] ||
v1[0] === v2[2] ||
v1[0] === v2[3]
) && (
v1[1] === v2[0] ||
v1[1] === v2[1] ||
v1[1] === v2[2] ||
v1[1] === v2[3]
) && (
v1[2] === v2[0] ||
v1[2] === v2[1] ||
v1[2] === v2[2] ||
v1[2] === v2[3]
) && (
v1[3] === v2[0] ||
v1[3] === v2[1] ||
v1[3] === v2[2] ||
v1[3] === v2[3]
)
);
};
vertices.forEach((v, i) => {
v.originalIndexes = [i];
});
for (let i = vertices.length - 1; i >= 0; i--) {
for (let ii = i - 1; ii >= 0; ii--) {
const v1 = vertices[i];
const v2 = vertices[ii];
if (compareVertices(v1, v2)) {
vertices.splice(i, 1);
v2.originalIndexes.push(...v1.originalIndexes);
break;
}
}
}
vertices.forEach((v, i) => {
polys.forEach(p => {
p.vIndexes.forEach((vi, ii, arr) => {
const vo = v.originalIndexes;
if (vo.includes(vi)) {
arr[ii] = i;
}
});
});
});
polys.forEach(p => {
const vi = p.vIndexes;
p.sum = vi[0] + vi[1] + vi[2] + vi[3];
});
polys.sort((a, b) => b.sum - a.sum);
// 假设:
// 1. 每个多边形要么没有重复,要么有 1 个重复
// 2. 如果两个多边形相等,那么它们都是隐藏的(两个立方体接触)
// 因此都可以被移除
for (let i = polys.length - 1; i >= 0; i--) {
for (let ii = i - 1; ii >= 0; ii--) {
const p1 = polys[i];
const p2 = polys[ii];
if (p1.sum !== p2.sum) break;
if (comparePolys(p1, p2)) {
polys.splice(i, 1);
polys.splice(ii, 1);
i--;
break;
}
}
}
return model;
}
// Entity.js
// ============================================================================
// ============================================================================
class Entity {
constructor({ model, color, wireframe = false }) {
const vertices = cloneVertices(model.vertices);
const shadowVertices = cloneVertices(model.vertices);
const colorHex = colorToHex(color);
const darkColorHex = shadeColor(color, 0.4);
const polys = model.polys.map(p => ({
vertices: p.vIndexes.map(vIndex => vertices[vIndex]),
color: color, // 自定义 rgb 颜色对象
wireframe: wireframe,
strokeWidth: wireframe ? 2 : 0, // 设置为非零值以绘制描边
strokeColor: colorHex, // 必须是 CSS 颜色字符串
strokeColorDark: darkColorHex, // 必须是 CSS 颜色字符串
depth: 0,
middle: { x: 0, y: 0, z: 0 },
normalWorld: { x: 0, y: 0, z: 0 },
normalCamera: { x: 0, y: 0, z: 0 }
}));
const shadowPolys = model.polys.map(p => ({
vertices: p.vIndexes.map(vIndex => shadowVertices[vIndex]),
wireframe: wireframe,
normalWorld: { x: 0, y: 0, z: 0 }
}));
this.projected = {}; // 将存储二维投影数据
this.model = model;
this.vertices = vertices;
this.polys = polys;
this.shadowVertices = shadowVertices;
this.shadowPolys = shadowPolys;
this.reset();
}
// 更好的命名:重置实体、重置变换、重置实体变换
reset() {
this.x = 0;
this.y = 0;
this.z = 0;
this.xD = 0;
this.yD = 0;
this.zD = 0;
this.rotateX = 0;
this.rotateY = 0;
this.rotateZ = 0;
this.rotateXD = 0;
this.rotateYD = 0;
this.rotateZD = 0;
this.scaleX = 1;
this.scaleY = 1;
this.scaleZ = 1;
this.projected.x = 0;
this.projected.y = 0;
}
transform() {
transformVertices(
this.model.vertices,
this.vertices,
this.x,
this.y,
this.z,
this.rotateX,
this.rotateY,
this.rotateZ,
this.scaleX,
this.scaleY,
this.scaleZ
);
copyVerticesTo(this.vertices, this.shadowVertices);
}
// 将原点点进行投影,存储为 `projected` 属性
project() {
projectVertexTo(this, this.projected);
}
}
// getTarget.js
// ============================================================================
// ============================================================================
// 全部激活的目标
const targets = [];
// 按照颜色对目标实例进行分组,使用映射(Map)来实现
// 键是颜色对象,值是目标数组。
// 另外,单独对线框实例进行分组
const targetPool = new Map(allColors.map(c => ([c, []])));
const targetWireframePool = new Map(allColors.map(c => ([c, []])));
const getTarget = (() => {
const slowmoSpawner = makeSpawner({
chance: 0.5,
cooldownPerSpawn: 10000,
maxSpawns: 1
});
let doubleStrong = false;
const strongSpawner = makeSpawner({
chance: 0.3,
cooldownPerSpawn: 12000,
maxSpawns: 1
});
const spinnerSpawner = makeSpawner({
chance: 0.1,
cooldownPerSpawn: 10000,
maxSpawns: 1
});
// 已缓存的数组实例,无需每次都分配内存
const axisOptions = [
['x', 'y'],
['y', 'z'],
['z', 'x']
];
function getTargetOfStyle(color, wireframe) {
const pool = wireframe ? targetWireframePool : targetPool;
let target = pool.get(color).pop();
if (!target) {
target = new Entity({
model: optimizeModel(makeRecursiveCubeModel({
recursionLevel: 1,
splitFn: mengerSpongeSplit,
scale: targetRadius
})),
color: color,
wireframe: wireframe
});
// 初始化将要使用的任何属性。
// 这些属性在被回收时不会自动重置。
target.color = color;
target.wireframe = wireframe;
// 一些属性尚未确定其最终值。
// 用任何正确类型的值进行初始化
target.hit = false;
target.maxHealth = 0;
target.health = 0;
}
return target;
}
return function getTarget() {
if (doubleStrong && state.game.score <= doubleStrongEnableScore) {
doubleStrong = false;
// 生成器在游戏重置时会自动重置
} else if (!doubleStrong && state.game.score > doubleStrongEnableScore) {
doubleStrong = true;
strongSpawner.mutate({ maxSpawns: 2 });
}
// 目标参数
// --------------------------------
let color = pickOne([BLUE, GREEN, ORANGE]);
let wireframe = false;
let health = 1;
let maxHealth = 3;
const spinner = state.game.cubeCount >= spinnerThreshold && isInGame() && spinnerSpawner.shouldSpawn();
// 目标参数覆盖
// --------------------------------
if (state.game.cubeCount >= slowmoThreshold && slowmoSpawner.shouldSpawn()) {
color = BLUE;
wireframe = true;
}
else if (state.game.cubeCount >= strongThreshold && strongSpawner.shouldSpawn()) {
color = PINK;
health = 3;
}
// 目标创建
// --------------------------------
const target = getTargetOfStyle(color, wireframe);
target.hit = false;
target.maxHealth = maxHealth;
target.health = health;
updateTargetHealth(target, 0);
const spinSpeeds = [
Math.random() * 0.1 - 0.05,
Math.random() * 0.1 - 0.05
];
if (spinner) {
// 变成了绕着一个随机轴旋转
spinSpeeds[0] = -0.25;
spinSpeeds[1] = 0;
target.rotateZ = random(0, TAU);
}
const axes = pickOne(axisOptions);
spinSpeeds.forEach((spinSpeed, i) => {
switch (axes[i]) {
case 'x':
target.rotateXD = spinSpeed;
break;
case 'y':
target.rotateYD = spinSpeed;
break;
case 'z':
target.rotateZD = spinSpeed;
break;
}
});
return target;
}
})();
const updateTargetHealth = (target, healthDelta) => {
target.health += healthDelta;
// 只在非线框目标上更新描边效果
// 当前显示的 '粘合效果'(glue)仅用于临时展示值目前没有理由让线框目标拥有高生命值
// 因此这个方案是可行的
if (!target.wireframe) {
const strokeWidth = target.health - 1;
const strokeColor = makeTargetGlueColor(target);
for (let p of target.polys) {
p.strokeWidth = strokeWidth;
p.strokeColor = strokeColor;
}
}
};
const returnTarget = target => {
target.reset();
const pool = target.wireframe ? targetWireframePool : targetPool;
pool.get(target.color).push(target);
};
function resetAllTargets() {
while (targets.length) {
returnTarget(targets.pop());
}
}
// createBurst.js
// ============================================================================
// ============================================================================
// 追踪所有活动中的方块碎片
const frags = [];
// 使用 Map 结构按颜色池化非活跃碎片
// 键(keys)是颜色对象,值(values)是碎片组成的数组
// // 同时将线框实例单独进行池化管理
const fragPool = new Map(allColors.map(c => ([c, []])));
const fragWireframePool = new Map(allColors.map(c => ([c, []])));
const createBurst = (() => {
// 预先计算一些要在所有突发中重复使用的私有数据
const basePositions = mengerSpongeSplit({ x: 0, y: 0, z: 0 }, fragRadius * 2);
const positions = cloneVertices(basePositions);
const prevPositions = cloneVertices(basePositions);
const velocities = cloneVertices(basePositions);
const basePositionNormals = basePositions.map(normalize);
const positionNormals = cloneVertices(basePositionNormals);
const fragCount = basePositions.length;
function getFragForTarget(target) {
const pool = target.wireframe ? fragWireframePool : fragPool;
let frag = pool.get(target.color).pop();
if (!frag) {
frag = new Entity({
model: makeCubeModel({ scale: fragRadius }),
color: target.color,
wireframe: target.wireframe
});
frag.color = target.color;
frag.wireframe = target.wireframe;
}
return frag;
}
return (target, force = 1) => {
// 计算片段位置,以及之前的位置
// 当仍然是更大目标的一部分时
transformVertices(
basePositions, positions,
target.x, target.y, target.z,
target.rotateX, target.rotateY, target.rotateZ,
1, 1, 1
);
transformVertices(
basePositions, prevPositions,
target.x - target.xD, target.y - target.yD, target.z - target.zD,
target.rotateX - target.rotateXD, target.rotateY - target.rotateYD, target.rotateZ - target.rotateZD,
1, 1, 1
);
// 根据之前的位置计算每个片段的速度
// 将写入 'velocities' 数组
for (let i = 0; i < fragCount; i++) {
const position = positions[i];
const prevPosition = prevPositions[i];
const velocity = velocities[i];
velocity.x = position.x - prevPosition.x;
velocity.y = position.y - prevPosition.y;
velocity.z = position.z - prevPosition.z;
}
// 将目标旋转应用于法线
transformVertices(
basePositionNormals, positionNormals,
0, 0, 0,
target.rotateX, target.rotateY, target.rotateZ,
1, 1, 1
);
for (let i = 0; i < fragCount; i++) {
const position = positions[i];
const velocity = velocities[i];
const normal = positionNormals[i];
const frag = getFragForTarget(target);
frag.x = position.x;
frag.y = position.y;
frag.z = position.z;
frag.rotateX = target.rotateX;
frag.rotateY = target.rotateY;
frag.rotateZ = target.rotateZ;
const burstSpeed = 2 * force;
const randSpeed = 2 * force;
const rotateScale = 0.015;
frag.xD = velocity.x + (normal.x * burstSpeed) + (Math.random() * randSpeed);
frag.yD = velocity.y + (normal.y * burstSpeed) + (Math.random() * randSpeed);
frag.zD = velocity.z + (normal.z * burstSpeed) + (Math.random() * randSpeed);
frag.rotateXD = frag.xD * rotateScale;
frag.rotateYD = frag.yD * rotateScale;
frag.rotateZD = frag.zD * rotateScale;
frags.push(frag);
};
}
})();
const returnFrag = frag => {
frag.reset();
const pool = frag.wireframe ? fragWireframePool : fragPool;
pool.get(frag.color).push(frag);
};
// sparks.js
// ============================================================================
// ============================================================================
const sparks = [];
const sparkPool = [];
function addSpark(x, y, xD, yD) {
const spark = sparkPool.pop() || {};
spark.x = x + xD * 0.5;
spark.y = y + yD * 0.5;
spark.xD = xD;
spark.yD = yD;
spark.life = random(200, 300);
spark.maxLife = spark.life;
sparks.push(spark);
return spark;
}
// 球形火花爆发
function sparkBurst(x, y, count, maxSpeed) {
const angleInc = TAU / count;
for (let i = 0; i < count; i++) {
const angle = i * angleInc + angleInc * Math.random();
const speed = (1 - Math.random() ** 3) * maxSpeed;
addSpark(
x,
y,
Math.sin(angle) * speed,
Math.cos(angle) * speed
);
}
}
// 使目标从所有顶点“泄漏”火花
// 这用于创建目标胶水“脱落”的效果
let glueShedVertices;
function glueShedSparks(target) {
if (!glueShedVertices) {
glueShedVertices = cloneVertices(target.vertices);
} else {
copyVerticesTo(target.vertices, glueShedVertices);
}
glueShedVertices.forEach(v => {
if (Math.random() < 0.4) {
projectVertex(v);
addSpark(
v.x,
v.y,
random(-12, 12),
random(-12, 12)
);
}
});
}
function returnSpark(spark) {
sparkPool.push(spark);
}
// hud.js
// ============================================================================
// ============================================================================
const hudContainerNode = $('.hud');
function setHudVisibility(visible) {
if (visible) {
hudContainerNode.style.display = 'block';
} else {
hudContainerNode.style.display = 'none';
}
}
///////////
// 得分 //
///////////
const scoreNode = $('.score-lbl');
const cubeCountNode = $('.cube-count-lbl');
function renderScoreHud() {
if (isCasualGame()) {
scoreNode.style.display = 'none';
cubeCountNode.style.opacity = 1;
} else {
scoreNode.innerText = `得分: ${state.game.score}`;
scoreNode.style.display = 'block';
cubeCountNode.style.opacity = 0.65;
}
cubeCountNode.innerText = `当前已切快: ${state.game.cubeCount}`;
}
renderScoreHud();
//////////////////
// 暂停按钮 //
//////////////////
handlePointerDown($('.pause-btn'), () => pauseGame());
////////////////////
// 慢动作状态 //
////////////////////
const slowmoNode = $('.slowmo');
const slowmoBarNode = $('.slowmo__bar');
function renderSlowmoStatus(percentRemaining) {
slowmoNode.style.opacity = percentRemaining === 0 ? 0 : 1;
slowmoBarNode.style.transform = `scaleX(${percentRemaining.toFixed(3)})`;
}
// menus.js
// ============================================================================
// ============================================================================
// 顶级菜单容器
const menuContainerNode = $('.menus');
const menuMainNode = $('.menu--main');
const menuPauseNode = $('.menu--pause');
const menuScoreNode = $('.menu--score');
const finalScoreLblNode = $('.final-score-lbl');
const highScoreLblNode = $('.high-score-lbl');
function showMenu(node) {
node.classList.add('active');
}
function hideMenu(node) {
node.classList.remove('active');
}
function renderMenus() {
hideMenu(menuMainNode);
hideMenu(menuPauseNode);
hideMenu(menuScoreNode);
switch (state.menus.active) {
case MENU_MAIN:
showMenu(menuMainNode);
break;
case MENU_PAUSE:
showMenu(menuPauseNode);
break;
case MENU_SCORE:
finalScoreLblNode.textContent = formatNumber(state.game.score);
if (state.game.score > getHighScore()) {
highScoreLblNode.textContent = '最高得分';
} else {
highScoreLblNode.textContent = `最高得分: ${formatNumber(getHighScore())}`;
}
showMenu(menuScoreNode);
break;
}
setHudVisibility(!isMenuVisible());
menuContainerNode.classList.toggle('has-active', isMenuVisible());
menuContainerNode.classList.toggle('interactive-mode', isMenuVisible() && pointerIsDown);
}
renderMenus();
////////////////////
// 按钮作 //
////////////////////
// 主菜单
handleClick($('.play-normal-btn'), () => {
setGameMode(GAME_MODE_RANKED);
setActiveMenu(null);
resetGame();
});
handleClick($('.play-casual-btn'), () => {
setGameMode(GAME_MODE_CASUAL);
setActiveMenu(null);
resetGame();
});
// 暂停菜单
handleClick($('.resume-btn'), () => resumeGame());
handleClick($('.menu-btn--pause'), () => setActiveMenu(MENU_MAIN));
// 菜单
handleClick($('.play-again-btn'), () => {
setActiveMenu(null);
resetGame();
});
handleClick($('.menu-btn--score'), () => setActiveMenu(MENU_MAIN));
// actions.js
// ============================================================================
// ============================================================================
//////////////////
// 菜单 //
//////////////////
function setActiveMenu(menu) {
state.menus.active = menu;
renderMenus();
}
/////////////////
// HUD ACTIONS //
/////////////////
function setScore(score) {
state.game.score = score;
renderScoreHud();
}
function incrementScore(inc) {
if (isInGame()) {
state.game.score += inc;
if (state.game.score < 0) {
state.game.score = 0;
}
renderScoreHud();
}
}
function setCubeCount(count) {
state.game.cubeCount = count;
renderScoreHud();
}
function incrementCubeCount(inc) {
if (isInGame()) {
state.game.cubeCount += inc;
renderScoreHud();
}
}
//////////////////
// 游戏动作 //
//////////////////
function setGameMode(mode) {
state.game.mode = mode;
}
function resetGame() {
resetAllTargets();
state.game.time = 0;
resetAllCooldowns();
setScore(0);
setCubeCount(0);
spawnTime = getSpawnDelay();
}
function pauseGame() {
isInGame() && setActiveMenu(MENU_PAUSE);
}
function resumeGame() {
isPaused() && setActiveMenu(null);
}
function endGame() {
setActiveMenu(MENU_SCORE);
handleCanvasPointerUp();
// 如果需要,在渲染分数菜单后更新高分
if (state.game.score > getHighScore()) {
setHighScore(state.game.score);
}
}
////////////////////////
// 键盘快捷键 //
////////////////////////
window.addEventListener('keydown', event => {
if (event.key === 'p') {
isPaused() ? resumeGame() : pauseGame();
}
});
// tick.js
// ============================================================================
// ============================================================================
let spawnTime = 0;
const maxSpawnX = 450;
const pointerDelta = { x: 0, y: 0 };
const pointerDeltaScaled = { x: 0, y: 0 };
// 临时慢动作状态。一旦稳定下来,就应该重新安置
const slowmoDuration = 1500;
let slowmoRemaining = 0;
let spawnExtra = 0;
const spawnExtraDelay = 300;
let targetSpeed = 1;
function tick(width, height, simTime, simSpeed, lag) {
PERF_START('frame');
PERF_START('tick');
state.game.time += simTime;
if (slowmoRemaining > 0) {
slowmoRemaining -= simTime;
if (slowmoRemaining < 0) {
slowmoRemaining = 0;
}
targetSpeed = pointerIsDown ? 0.075 : 0.3;
} else {
const menuPointerDown = isMenuVisible() && pointerIsDown;
targetSpeed = menuPointerDown ? 0.025 : 1;
}
renderSlowmoStatus(slowmoRemaining / slowmoDuration);
gameSpeed += (targetSpeed - gameSpeed) / 22 * lag;
gameSpeed = clamp(gameSpeed, 0, 1);
const centerX = width / 2;
const centerY = height / 2;
const simAirDrag = 1 - (airDrag * simSpeed);
const simAirDragSpark = 1 - (airDragSpark * simSpeed);
// 指针跟踪
// -------------------
// Compute speed and x/y deltas.
// There is also a "scaled" variant taking game speed into account. This serves two purposes:
// - Lag won't create large spikes in speed/deltas
// - In slow mo, speed is increased proportionately to match "reality". Without this boost,
// it feels like your actions are dampened in slow mo.
const forceMultiplier = 1 / (simSpeed * 0.75 + 0.25);
pointerDelta.x = 0;
pointerDelta.y = 0;
pointerDeltaScaled.x = 0;
pointerDeltaScaled.y = 0;
const lastPointer = touchPoints[touchPoints.length - 1];
if (pointerIsDown && lastPointer && !lastPointer.touchBreak) {
pointerDelta.x = (pointerScene.x - lastPointer.x);
pointerDelta.y = (pointerScene.y - lastPointer.y);
pointerDeltaScaled.x = pointerDelta.x * forceMultiplier;
pointerDeltaScaled.y = pointerDelta.y * forceMultiplier;
}
const pointerSpeed = Math.hypot(pointerDelta.x, pointerDelta.y);
const pointerSpeedScaled = pointerSpeed * forceMultiplier;
// Track points for later calculations, including drawing trail.
touchPoints.forEach(p => p.life -= simTime);
if (pointerIsDown) {
touchPoints.push({
x: pointerScene.x,
y: pointerScene.y,
life: touchPointLife
});
}
while (touchPoints[0] && touchPoints[0].life <= 0) {
touchPoints.shift();
}
// Entity Manipulation
// --------------------
PERF_START('entities');
// Spawn targets
spawnTime -= simTime;
if (spawnTime <= 0) {
if (spawnExtra > 0) {
spawnExtra--;
spawnTime = spawnExtraDelay;
} else {
spawnTime = getSpawnDelay();
}
const target = getTarget();
const spawnRadius = Math.min(centerX * 0.8, maxSpawnX);
target.x = (Math.random() * spawnRadius * 2 - spawnRadius);
target.y = centerY + targetHitRadius;
target.z = (Math.random() * targetRadius * 2 - targetRadius);
target.xD = Math.random() * (target.x * -2 / 120);
target.yD = -20;
targets.push(target);
}
// 为目标设置动画并在屏幕外删除
const leftBound = -centerX + targetRadius;
const rightBound = centerX - targetRadius;
const ceiling = -centerY - 120;
const boundDamping = 0.4;
targetLoop:
for (let i = targets.length - 1; i >= 0; i--) {
const target = targets[i];
target.x += target.xD * simSpeed;
target.y += target.yD * simSpeed;
if (target.y < ceiling) {
target.y = ceiling;
target.yD = 0;
}
if (target.x < leftBound) {
target.x = leftBound;
target.xD *= -boundDamping;
} else if (target.x > rightBound) {
target.x = rightBound;
target.xD *= -boundDamping;
}
if (target.z < backboardZ) {
target.z = backboardZ;
target.zD *= -boundDamping;
}
target.yD += gravity * simSpeed;
target.rotateX += target.rotateXD * simSpeed;
target.rotateY += target.rotateYD * simSpeed;
target.rotateZ += target.rotateZD * simSpeed;
target.transform();
target.project();
// 如果离开屏幕,则删除
if (target.projected.y > centerY + targetHitRadius * 2) {
targets.splice(i, 1);
returnTarget(target);
if (isInGame()) {
if (isCasualGame()) {
incrementScore(-25);
} else {
endGame();
}
}
continue;
}
// 如果指针移动得非常快,我们希望沿路径进行命中测试多个点。
// 我们不能使用缩放的指针速度来确定这一点,因为我们关心的是实际屏幕
// 覆盖的距离。
const hitTestCount = Math.ceil(pointerSpeed / targetRadius * 2);
// 从“1”开始循环并使用“<=”检查,因此我们跳过 0% 并最终达到 100%。
// 这省略了上一个点位置,并包括最近的点位置。
for (let ii = 1; ii <= hitTestCount; ii++) {
const percent = 1 - (ii / hitTestCount);
const hitX = pointerScene.x - pointerDelta.x * percent;
const hitY = pointerScene.y - pointerDelta.y * percent;
const distance = Math.hypot(
hitX - target.projected.x,
hitY - target.projected.y
);
if (distance <= targetHitRadius) {
// 打!(尽管我们不想允许在多个连续帧上命中)
if (!target.hit) {
target.hit = true;
target.xD += pointerDeltaScaled.x * hitDampening;
target.yD += pointerDeltaScaled.y * hitDampening;
target.rotateXD += pointerDeltaScaled.y * 0.001;
target.rotateYD += pointerDeltaScaled.x * 0.001;
const sparkSpeed = 7 + pointerSpeedScaled * 0.125;
if (pointerSpeedScaled > minPointerSpeed) {
target.health--;
incrementScore(10);
if (target.health <= 0) {
incrementCubeCount(1);
createBurst(target, forceMultiplier);
sparkBurst(hitX, hitY, 8, sparkSpeed);
if (target.wireframe) {
slowmoRemaining = slowmoDuration;
spawnTime = 0;
spawnExtra = 2;
}
targets.splice(i, 1);
returnTarget(target);
} else {
sparkBurst(hitX, hitY, 8, sparkSpeed);
glueShedSparks(target);
updateTargetHealth(target, 0);
}
} else {
incrementScore(5);
sparkBurst(hitX, hitY, 3, sparkSpeed);
}
}
// 断开电流循环并继续外部循环。
// 这将跳到处理下一个目标。
continue targetLoop;
}
}
// 此代码仅在目标未被“命中”时运行。
target.hit = false;
}
// 为片段设置动画并在屏幕外删除。
const fragBackboardZ = backboardZ + fragRadius;
// 允许片段在屏幕外移动到两侧一段时间,因为阴影仍然可见。
const fragLeftBound = -width;
const fragRightBound = width;
for (let i = frags.length - 1; i >= 0; i--) {
const frag = frags[i];
frag.x += frag.xD * simSpeed;
frag.y += frag.yD * simSpeed;
frag.z += frag.zD * simSpeed;
frag.xD *= simAirDrag;
frag.yD *= simAirDrag;
frag.zD *= simAirDrag;
if (frag.y < ceiling) {
frag.y = ceiling;
frag.yD = 0;
}
if (frag.z < fragBackboardZ) {
frag.z = fragBackboardZ;
frag.zD *= -boundDamping;
}
frag.yD += gravity * simSpeed;
frag.rotateX += frag.rotateXD * simSpeed;
frag.rotateY += frag.rotateYD * simSpeed;
frag.rotateZ += frag.rotateZD * simSpeed;
frag.transform();
frag.project();
// 移除条件
if (
// 屏幕底部
frag.projected.y > centerY + targetHitRadius ||
// 屏幕侧面
frag.projected.x < fragLeftBound ||
frag.projected.x > fragRightBound ||
// 离相机太近
frag.z > cameraFadeEndZ
) {
frags.splice(i, 1);
returnFrag(frag);
continue;
}
}
// 2D 火花
for (let i = sparks.length - 1; i >= 0; i--) {
const spark = sparks[i];
spark.life -= simTime;
if (spark.life <= 0) {
sparks.splice(i, 1);
returnSpark(spark);
continue;
}
spark.x += spark.xD * simSpeed;
spark.y += spark.yD * simSpeed;
spark.xD *= simAirDragSpark;
spark.yD *= simAirDragSpark;
spark.yD += gravity * simSpeed;
}
PERF_END('entities');
// 3D 变换
// -------------------
PERF_START('3D');
// 聚合所有场景顶点/多边形
allVertices.length = 0;
allPolys.length = 0;
allShadowVertices.length = 0;
allShadowPolys.length = 0;
targets.forEach(entity => {
allVertices.push(...entity.vertices);
allPolys.push(...entity.polys);
allShadowVertices.push(...entity.shadowVertices);
allShadowPolys.push(...entity.shadowPolys);
});
frags.forEach(entity => {
allVertices.push(...entity.vertices);
allPolys.push(...entity.polys);
allShadowVertices.push(...entity.shadowVertices);
allShadowPolys.push(...entity.shadowPolys);
});
// 场景计算/转换
allPolys.forEach(p => computePolyNormal(p, 'normalWorld'));
allPolys.forEach(computePolyDepth);
allPolys.sort((a, b) => b.depth - a.depth);
// 透视投影
allVertices.forEach(projectVertex);
allPolys.forEach(p => computePolyNormal(p, 'normalCamera'));
PERF_END('3D');
PERF_START('shadows');
// 将阴影顶点旋转到光源透视
transformVertices(
allShadowVertices,
allShadowVertices,
0, 0, 0,
TAU / 8, 0, 0,
1, 1, 1
);
allShadowPolys.forEach(p => computePolyNormal(p, 'normalWorld'));
const shadowDistanceMult = Math.hypot(1, 1);
const shadowVerticesLength = allShadowVertices.length;
for (let i = 0; i < shadowVerticesLength; i++) {
const distance = allVertices[i].z - backboardZ;
allShadowVertices[i].z -= shadowDistanceMult * distance;
}
transformVertices(
allShadowVertices,
allShadowVertices,
0, 0, 0,
-TAU / 8, 0, 0,
1, 1, 1
);
allShadowVertices.forEach(projectVertex);
PERF_END('shadows');
PERF_END('tick');
}
// draw.js
// ============================================================================
// ============================================================================
function draw(ctx, width, height, viewScale) {
PERF_START('draw');
const halfW = width / 2;
const halfH = height / 2;
// 3D 多边形
// ---------------
ctx.lineJoin = 'bevel';
PERF_START('drawShadows');
ctx.fillStyle = shadowColor;
ctx.strokeStyle = shadowColor;
allShadowPolys.forEach(p => {
if (p.wireframe) {
ctx.lineWidth = 2;
ctx.beginPath();
const { vertices } = p;
const vCount = vertices.length;
const firstV = vertices[0];
ctx.moveTo(firstV.x, firstV.y);
for (let i = 1; i < vCount; i++) {
const v = vertices[i];
ctx.lineTo(v.x, v.y);
}
ctx.closePath();
ctx.stroke();
} else {
ctx.beginPath();
const { vertices } = p;
const vCount = vertices.length;
const firstV = vertices[0];
ctx.moveTo(firstV.x, firstV.y);
for (let i = 1; i < vCount; i++) {
const v = vertices[i];
ctx.lineTo(v.x, v.y);
}
ctx.closePath();
ctx.fill();
}
});
PERF_END('drawShadows');
PERF_START('drawPolys');
allPolys.forEach(p => {
if (!p.wireframe && p.normalCamera.z < 0) return;
if (p.strokeWidth !== 0) {
ctx.lineWidth = p.normalCamera.z < 0 ? p.strokeWidth * 0.5 : p.strokeWidth;
ctx.strokeStyle = p.normalCamera.z < 0 ? p.strokeColorDark : p.strokeColor;
}
const { vertices } = p;
const lastV = vertices[vertices.length - 1];
const fadeOut = p.middle.z > cameraFadeStartZ;
if (!p.wireframe) {
const normalLight = p.normalWorld.y * 0.5 + p.normalWorld.z * -0.5;
const lightness = normalLight > 0
? 0.1
: ((normalLight ** 32 - normalLight) / 2) * 0.9 + 0.1;
ctx.fillStyle = shadeColor(p.color, lightness);
}
// 淡出靠近摄像机的多边形。'globalAlpha' 必须稍后重置。
if (fadeOut) {
// 如果多边形非常靠近摄像机(在'cameraFadeRange'之外),则alpha
// 可以变为负值,其外观为 alpha = 1。因此,我们将其限制在 0。
ctx.globalAlpha = Math.max(0, 1 - (p.middle.z - cameraFadeStartZ) / cameraFadeRange);
}
ctx.beginPath();
ctx.moveTo(lastV.x, lastV.y);
for (let v of vertices) {
ctx.lineTo(v.x, v.y);
}
if (!p.wireframe) {
ctx.fill();
}
if (p.strokeWidth !== 0) {
ctx.stroke();
}
if (fadeOut) {
ctx.globalAlpha = 1;
}
});
PERF_END('drawPolys');
PERF_START('draw2D');
// 2D 火花
// ---------------
ctx.strokeStyle = sparkColor;
ctx.lineWidth = sparkThickness;
ctx.beginPath();
sparks.forEach(spark => {
ctx.moveTo(spark.x, spark.y);
// 火花死亡时将火花缩小到零长度。
// 当生命接近 0(根曲线)时加速收缩。
// 请注意,随着时间的推移,火花已经随着速度的减慢而变小
// 从阻尼中下降。所以这就像双重缩小。为了应对这种情况
// 稍微保持火花更大更长时间,我们还将增加规模
// 应用根曲线后一点。
const scale = (spark.life / spark.maxLife) ** 0.5 * 1.5;
ctx.lineTo(spark.x - spark.xD * scale, spark.y - spark.yD * scale);
});
ctx.stroke();
// 触摸笔触
// ---------------
ctx.strokeStyle = touchTrailColor;
const touchPointCount = touchPoints.length;
for (let i = 1; i < touchPointCount; i++) {
const current = touchPoints[i];
const prev = touchPoints[i - 1];
if (current.touchBreak || prev.touchBreak) {
continue;
}
const scale = current.life / touchPointLife;
ctx.lineWidth = scale * touchTrailThickness;
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(current.x, current.y);
ctx.stroke();
}
PERF_END('draw2D');
PERF_END('draw');
PERF_END('frame');
// 显示性能更新。
PERF_UPDATE();
}
// canvas.js
// ============================================================================
// ============================================================================
function setupCanvases() {
const ctx = canvas.getContext('2d');
// devicePixelRatio 别名
const dpr = window.devicePixelRatio || 1;
// 视图将被缩放,以便对象在所有屏幕尺寸上显示大小相似。
let viewScale;
// 尺寸(考虑 viewScale!)
let width, height;
function handleResize() {
const w = window.innerWidth;
const h = window.innerHeight;
viewScale = h / 1000;
width = w / viewScale;
height = h / viewScale;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
}
// 设置初始大小
handleResize();
// 调整全屏画布大小
window.addEventListener('resize', handleResize);
// 运行游戏循环
let lastTimestamp = 0;
function frameHandler(timestamp) {
let frameTime = timestamp - lastTimestamp;
lastTimestamp = timestamp;
// 始终将另一帧排队
raf();
// 如果游戏暂停,我们仍将跟踪 frameTime(上图),但所有其他
// 可以避免游戏逻辑和绘图。
if (isPaused()) return;
// 确保不报告负数时间(第一帧可能很奇怪)
if (frameTime < 0) {
frameTime = 17;
}
// - 将最小帧速率限制为 15fps[~68ms](假设 60fps[~17ms] 为“正常”)
else if (frameTime > 68) {
frameTime = 68;
}
const halfW = width / 2;
const halfH = height / 2;
// Convert pointer position from screen to scene coords.
pointerScene.x = pointerScreen.x / viewScale - halfW;
pointerScene.y = pointerScreen.y / viewScale - halfH;
const lag = frameTime / 16.6667;
const simTime = gameSpeed * frameTime;
const simSpeed = gameSpeed * lag;
tick(width, height, simTime, simSpeed, lag);
// Auto clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Auto scale drawing for high res displays, and incorporate `viewScale`.
// Also shift canvas so (0, 0) is the middle of the screen.
// This just works with 3D perspective projection.
const drawScale = dpr * viewScale;
ctx.scale(drawScale, drawScale);
ctx.translate(halfW, halfH);
draw(ctx, width, height, viewScale);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
const raf = () => requestAnimationFrame(frameHandler);
// Start loop
raf();
}
// interaction.js
// ============================================================================
// ============================================================================
// Interaction
// -----------------------------
function handleCanvasPointerDown(x, y) {
if (!pointerIsDown) {
pointerIsDown = true;
pointerScreen.x = x;
pointerScreen.y = y;
// On when menus are open, point down/up toggles an interactive mode.
// We just need to rerender the menu system for it to respond.
if (isMenuVisible()) renderMenus();
}
}
function handleCanvasPointerUp() {
if (pointerIsDown) {
pointerIsDown = false;
touchPoints.push({
touchBreak: true,
life: touchPointLife
});
// On when menus are open, point down/up toggles an interactive mode.
// We just need to rerender the menu system for it to respond.
if (isMenuVisible()) renderMenus();
}
}
function handleCanvasPointerMove(x, y) {
if (pointerIsDown) {
pointerScreen.x = x;
pointerScreen.y = y;
}
}
// Use pointer events if available, otherwise fallback to touch events (for iOS).
if ('PointerEvent' in window) {
canvas.addEventListener('pointerdown', event => {
event.isPrimary && handleCanvasPointerDown(event.clientX, event.clientY);
});
canvas.addEventListener('pointerup', event => {
event.isPrimary && handleCanvasPointerUp();
});
canvas.addEventListener('pointermove', event => {
event.isPrimary && handleCanvasPointerMove(event.clientX, event.clientY);
});
// We also need to know if the mouse leaves the page. For this game, it's best if that
// cancels a swipe, so essentially acts as a "mouseup" event.
document.body.addEventListener('mouseleave', handleCanvasPointerUp);
} else {
let activeTouchId = null;
canvas.addEventListener('touchstart', event => {
if (!pointerIsDown) {
const touch = event.changedTouches[0];
activeTouchId = touch.identifier;
handleCanvasPointerDown(touch.clientX, touch.clientY);
}
});
canvas.addEventListener('touchend', event => {
for (let touch of event.changedTouches) {
if (touch.identifier === activeTouchId) {
handleCanvasPointerUp();
break;
}
}
});
canvas.addEventListener('touchmove', event => {
for (let touch of event.changedTouches) {
if (touch.identifier === activeTouchId) {
handleCanvasPointerMove(touch.clientX, touch.clientY);
event.preventDefault();
break;
}
}
}, { passive: false });
}
// index.js
// ============================================================================
// ============================================================================
setupCanvases();
预览