🏃极简线条跑酷PARKOUR
by RocketTiger92484 lines25.0 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<!-- 页面编码声明:告诉浏览器用 UTF-8 解码,保证中文显示正常 -->
<meta charset="UTF-8">
<!-- viewport 设置:让页面在手机和平板上按比例缩放,适配不同设备 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 页面标题:浏览器标签页中显示的内容 -->
<title>🏃极简线条跑酷PARKOUR</title>
<style>
/* :root 用来定义全局变量,统一管理样式颜色,便于后期修改 */
:root { --bg: #f0f0f0; }
/* 全局元素重置:清除默认 margin 和 padding,让布局更统一 */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* body 是页面主体,设置背景、居中布局和禁止用户选中 */
body {
background: #e0e0e0; /* 页面背景采用浅灰色,符合黑白极简风 */
display: flex; /* 使用弹性布局,让内容在页面中居中 */
justify-content: center; /* 主轴居中:左右居中 */
align-items: center; /* 交叉轴居中:上下居中 */
min-height: 100vh; /* 最小高度为整个视口高度,保证全屏铺满 */
font-family: 'Courier New', 'PingFang SC', monospace; /* 使用等宽字体,像代码风格,适合游戏界面 */
overflow: hidden; /* 隐藏滚动条,避免页面出现滚动 */
user-select: none; /* 禁止文本被选中,增强游戏体验 */
-webkit-user-select: none; /* Safari / WebKit 浏览器兼容处理 */
cursor: pointer; /* 鼠标在页面上时显示手型,提示可点击 */
}
/* 游戏容器:包住canvas和提示条,作为游戏整体展示区域 */
.game-wrapper {
position: relative; /* 作为定位参考点,用于提示条绝对定位 */
border: 4px solid #111; /* 黑色细边框,体现极简线条风 */
border-radius: 12px; /* 圆角,让界面更柔和 */
overflow: hidden; /* 隐藏子元素超出容器范围的部分 */
box-shadow: 8px 8px 0 #00000020; /* 轻微投影,增加立体感 */
transition: transform 0.1s ease; /* 缩放过渡,点击时更自然 */
max-width: 95vw; /* 最大宽度不超过视口 95% */
max-height: 90vh; /* 最大高度不超过视口 90% */
}
/* 按下容器时轻微缩小,给予交互反馈 */
.game-wrapper:active { transform: scale(0.995); }
/* canvas 画布样式:块级元素,宽高自适应 */
canvas { display: block; max-width: 100%; height: auto; }
/* 提示条:显示在游戏下方,提示用户如何操作 */
.hint-bar {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); /* 左50% + 向左平移自身一半,达到水平居中 */
background: #fff; color: #111; padding: 6px 18px; border-radius: 20px; /* 白底黑字,胶囊按钮效果 */
font-size: 13px; letter-spacing: 0.5px; pointer-events: none; /* 提示条不接收鼠标事件,避免挡住点击 */
border: 2px solid #111; transition: opacity 0.3s; font-weight: bold; /* 边框和透明度过渡 */
}
</style>
</head>
<body>
<!-- 游戏外层容器:所有游戏内容都放在这里 -->
<div class="game-wrapper" id="gameWrapper">
<!-- canvas 负责实际绘制游戏场景(背景、地面、玩家、障碍物等) -->
<canvas id="gameCanvas"></canvas>
<!-- 提示条初始化内容:告诉玩家使用 空格或点击 跳跃 -->
<div class="hint-bar" id="hintBar">[ 空格 / 点击 ] 跳跃</div>
</div>
<script>
// 立即执行函数:避免变量泄漏到全局作用域,防止命名冲突
(function() {
// 获取画布元素和绘图上下文,后续所有图像绘制都依赖它们
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const hintBar = document.getElementById('hintBar');
// 设置画布固定宽高,保证坐标系稳定,便于精确控制游戏对象位置
const WIDTH = 800, HEIGHT = 420;
canvas.width = WIDTH; canvas.height = HEIGHT;
// 自适应窗口大小:根据浏览器窗口调整 canvas 的显示尺寸,但内部真实分辨率保持不变
function resizeCanvas() {
const scale = Math.min((window.innerWidth * 0.95) / WIDTH, (window.innerHeight * 0.88) / HEIGHT, 1.0);
canvas.style.width = WIDTH * scale + 'px';
canvas.style.height = HEIGHT * scale + 'px';
}
resizeCanvas(); // 页面初始化时执行一次,确保画布尺寸正确
window.addEventListener('resize', resizeCanvas); // 窗口大小改变时重新计算比例
// -------------------- 游戏参数 --------------------
const GROUND_Y = 340; // 地面Y坐标:角色脚底落到这个高度时算站在地面上
const PLAYER_X = 130; // 玩家角色固定的横向位置,便于控制跳跃和碰撞
const GRAVITY = 1800; // 重力 px/s²:数值越大,掉落越快
const JUMP_VEL = -620; // 跳跃初速度 px/s:负值表示向上,速度越大跳得越高
const SPEED = 190; // 障碍物移动速度 px/s:越大,越快向左滑出屏幕
const SCORE_RATE = 10; // 每秒得分:每秒持续生存可累积分数
const SPAWN_MIN = 0.54, SPAWN_MAX = 1.4; // 障碍物生成间隔范围(秒),随机在这个区间内生成
// -------------------- 状态 --------------------
const STATE = { WAIT: 0, PLAY: 1, OVER: 2 }; // 定义三种状态:等待、游戏中、结束
let gameState = STATE.WAIT; // 当前游戏状态,初始为等待开始
let score = 0, scoreAcc = 0; // score 是实际分数,scoreAcc 是累计时间,用于计算分数
let playerY = GROUND_Y, playerVy = 0, onGround = true; // 玩家位置、垂直速度、是否在地面
let obstacles = [], spawnTimer = 0; // obstacles 保存障碍物数组,spawnTimer 控制生成计时
let shake = 0, overAlpha = 0, restartCD = 0; // shake: 屏幕抖动强度,overAlpha:结束遮罩透明度,restartCD:重启冷却
let lastTime = performance.now(); // 记住上一帧时间,用于计算 dt(每帧时间差)
// -------------------- 简易音效 (保留,但不影响黑白风格) --------------------
let audioCtx = null; // 音频上下文对象,播放音效时需要它
function getCtx() {
if (!audioCtx) try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {} // 浏览器兼容:创建 AudioContext
if (audioCtx?.state === 'suspended') audioCtx.resume(); // 如果音频被暂停,则恢复播放
return audioCtx; // 返回音频上下文对象
}
function beep(freq, dur, type='square', vol=0.06) {
const ctx = getCtx(); if(!ctx) return; // 没有音频环境就直接退出,不报错
const t = ctx.currentTime; // 当前音频播放时间
const o = ctx.createOscillator(), g = ctx.createGain(); // 创建振荡器和增益器
o.type = type; o.frequency.setValueAtTime(freq, t); // 设置波形和频率
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t+dur); // 音量线性衰减到几乎为0
o.connect(g); g.connect(ctx.destination); // 连接到扬声器
o.start(t); o.stop(t+dur); // 在指定时长后停止,形成短促的声音效果
}
function sfxJump() { beep(600,0.08); setTimeout(()=>beep(800,0.06),40); } // 跳跃音效:两个高低不同的音调叠加
function sfxHit() { beep(60,0.35,'sawtooth',0.12,25);
beep(90,0.25,'triangle',0.08,40); } // 碰撞音效:低频震动感更强,模拟撞击
function sfxScore() { beep(1200,0.04,'sine',0.03); } // 得分音效:短促高音,反馈玩家进步
// -------------------- 障碍物生成 --------------------
function spawn() {
const sizes = [32, 42, 52, 62, 74]; // 可选高度数组,随机选择障碍物高度
const h = sizes[Math.floor(Math.random()*sizes.length)]; // 随机障碍高度
const w = 12 + h*0.15; // 宽度由高度决定,视觉上更协调
obstacles.push({ x: WIDTH+10, y: GROUND_Y-h, w, h }); // 生成在右侧屏幕外,准备从右向左前进
}
// -------------------- 碰撞检测 (收缩框) --------------------
function hitTest(pBox, obs) {
const m = 6; // 缩小量:让物体边缘碰撞感更友好,避免过于死板
const px = pBox.x+m, py = pBox.y+m, pw = pBox.w-m*2, ph = pBox.h-m*2; // 玩家碰撞框按边距缩小
const ox = obs.x - obs.w/2 + m*0.6, oy = obs.y + m*0.4, ow = obs.w - m*1.2, oh = obs.h - m*0.8; // 障碍物碰撞框也缩小
return px < ox+ow && px+pw > ox && py < oy+oh && py+ph > oy; // 两个矩形相交则返回 true
}
function playerBox() {
return { x: PLAYER_X-14, y: playerY-58, w: 28, h: 58 }; // 玩家矩形碰撞框,便于与障碍物矩形做交叉判断
}
// -------------------- 重置 --------------------
function reset() {
playerY = GROUND_Y; playerVy = 0; onGround = true; // 角色回到地面,速度归零
obstacles = []; score = 0; scoreAcc = 0; spawnTimer = 0.6; // 清空障碍、分数和计时器,准备新局
shake = 0; overAlpha = 0; restartCD = 0; // 随机抖动和结束动画重置
lastTime = performance.now(); // 从当前时间重新开始计算帧差
gameState = STATE.PLAY; // 进入游戏状态
hintBar.style.opacity = '0'; // 隐藏提示条,避免遮挡游戏视野
}
// -------------------- 绘制函数 (极简黑白线条风格) --------------------
function drawBackground() {
// 纯白背景:营造简洁、无噪音的画面
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, WIDTH, GROUND_Y);
// 极细横线表示远方:让画面不至于太空白,形成层次感
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 0.5;
for (let y=40; y<GROUND_Y; y+=35) {
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(WIDTH,y); ctx.stroke();
}
}
function drawGround() {
// 地面:纯白底色,黑色粗线边缘,保持黑白极简风格
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, GROUND_Y, WIDTH, HEIGHT-GROUND_Y);
ctx.strokeStyle = '#111';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(0, GROUND_Y); ctx.lineTo(WIDTH, GROUND_Y);
ctx.stroke();
// 地面纹理:短斜线,让地面不显得太平
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1;
for (let x=0; x<WIDTH; x+=25) {
const offset = (x*7)%30; // 计算偏移值,增加纹理随机感
ctx.beginPath(); // 斜线纹理,左上到右下方向
ctx.moveTo(x, GROUND_Y+4);
ctx.lineTo(x-6, GROUND_Y+14);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x+12, GROUND_Y+2);
ctx.lineTo(x+6, GROUND_Y+12);
ctx.stroke();
}
// 底部粗线:给画面底部增加边框感
ctx.strokeStyle = '#111';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, HEIGHT-1); ctx.lineTo(WIDTH, HEIGHT-1);
ctx.stroke();
}
// 极简火柴人:只用线条和圆圈构成角色,适合黑白风格
function drawPlayer(y, jumping) {
const headR = 12; // 头部半径
const headY = y - 58 + headR; // 头部中心 y 坐标
const bodyTop = headY + headR; // 身体上端位置
const bodyBottom = y - 16; // 身体下端位置
const legSpread = jumping ? 8 : 11; // 跳跃时腿更紧凑,站立时更展开
const armY = bodyTop + 5; // 手臂位置
ctx.save();
if (jumping) { ctx.translate(PLAYER_X, y); ctx.rotate(-0.07); ctx.translate(-PLAYER_X, -y); } // 在跳跃时轻微倾斜,增强动作感
// 所有绘制使用黑色线条,无填充,保证极简风
ctx.strokeStyle = '#111';
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 腿:下落或站立时有不同展现
ctx.beginPath();
ctx.moveTo(PLAYER_X, bodyBottom);
ctx.lineTo(PLAYER_X-legSpread, y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(PLAYER_X, bodyBottom);
ctx.lineTo(PLAYER_X+legSpread, y);
ctx.stroke();
// 身体:竖直的线段连接头和腿
ctx.beginPath();
ctx.moveTo(PLAYER_X, bodyTop);
ctx.lineTo(PLAYER_X, bodyBottom);
ctx.stroke();
// 手臂:根据跳跃状态改变摆动角度,做出动态感
const armAngle = jumping ? -0.8 : 0.3;
const armLen = 14;
ctx.beginPath();
ctx.moveTo(PLAYER_X, armY);
ctx.lineTo(PLAYER_X - Math.cos(armAngle)*armLen, armY - Math.sin(armAngle)*armLen);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(PLAYER_X, armY);
ctx.lineTo(PLAYER_X + Math.cos(armAngle)*armLen, armY - Math.sin(armAngle)*armLen);
ctx.stroke();
// 头部 (空心圆):用圆环表示头部轮廓
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.arc(PLAYER_X, headY, headR, 0, Math.PI*2);
ctx.stroke();
// 眼睛 (小点):黑点表示眼睛,简单直接
ctx.fillStyle = '#111';
ctx.beginPath();
ctx.arc(PLAYER_X-3.5, headY-2, 1.8, 0, Math.PI*2);
ctx.fill();
ctx.beginPath();
ctx.arc(PLAYER_X+3.5, headY-2, 1.8, 0, Math.PI*2);
ctx.fill();
// 微笑:通过弧线模拟笑脸
ctx.strokeStyle = '#111';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(PLAYER_X, headY+3, 4, 0.2*Math.PI, 0.8*Math.PI);
ctx.stroke();
ctx.restore();
}
// 极简仙人掌 (直线条构成):使用矩形和分支模拟障碍物
function drawCactus(obs) {
const cx = obs.x, w = obs.w, h = obs.h; // 障碍中心位置和尺寸
const left = cx - w/2, right = cx + w/2, top = obs.y, bottom = obs.y + h; // 障碍边界
ctx.strokeStyle = '#111';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
// 主体矩形:仙人掌主体骨架
ctx.beginPath();
ctx.moveTo(left, top);
ctx.lineTo(right, top);
ctx.lineTo(right, bottom);
ctx.lineTo(left, bottom);
ctx.closePath();
ctx.stroke();
// 中间竖线:增加纹理,避免遮挡物太单一
ctx.lineWidth = 1.5;
ctx.strokeStyle = '#333';
ctx.beginPath();
ctx.moveTo(cx, top+4);
ctx.lineTo(cx, bottom-4);
ctx.stroke();
// 侧枝 (根据高度):高度越高,分支越多
if (h >= 42) {
ctx.strokeStyle = '#111';
ctx.lineWidth = 2;
// 左枝
const branchY = top + h*0.35;
ctx.beginPath();
ctx.moveTo(left, branchY);
ctx.lineTo(left - w*0.55, branchY);
ctx.lineTo(left - w*0.55, branchY - h*0.2);
ctx.stroke();
// 右枝 (更高的仙人掌才有)
if (h >= 52) {
const branchY2 = top + h*0.5;
ctx.beginPath();
ctx.moveTo(right, branchY2);
ctx.lineTo(right + w*0.55, branchY2);
ctx.lineTo(right + w*0.55, branchY2 - h*0.18);
ctx.stroke();
}
}
// 顶部小刺:让障碍物顶部更有植物感
ctx.fillStyle = '#111';
for (let i=-1; i<=1; i++) {
ctx.beginPath();
ctx.arc(cx + i*4, top-1, 1.8, 0, Math.PI*2);
ctx.fill();
}
}
// UI:左上角分数 (黑白)
function drawUI() {
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#111';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(16, 14, 140, 38, 20);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#111';
ctx.font = 'bold 18px "Courier New", monospace';
ctx.textAlign = 'left';
ctx.fillText(`${Math.floor(score)}`, 30, 40);
}
// 游戏结束覆盖层:显示“GAME OVER”以及分数
function drawGameOver() {
if (overAlpha <= 0) return; // 结束动画未开始则不显示
const alpha = Math.min(1, overAlpha); // 透明度限制在 0~1
ctx.fillStyle = `rgba(255,255,255,${alpha*0.85})`; // 轻微白色透明覆盖层
ctx.fillRect(0,0,WIDTH,HEIGHT);
ctx.fillStyle = '#111';
ctx.font = 'bold 36px "Courier New", monospace';
ctx.textAlign = 'center';
ctx.fillText('GAME OVER', WIDTH/2, HEIGHT/2-20);
ctx.font = 'bold 20px "Courier New", monospace';
ctx.fillText(`SCORE: ${Math.floor(score)}`, WIDTH/2, HEIGHT/2+30);
const pulse = 0.7 + 0.3*Math.sin(performance.now()*0.005); // 让提示文字轻微闪动
ctx.font = '16px "Courier New", monospace';
ctx.fillStyle = `rgba(0,0,0,${pulse})`;
ctx.fillText('点击任意处重新开始', WIDTH/2, HEIGHT/2+65);
}
// -------------------- 更新逻辑 --------------------
function update(dt) {
const dtClamp = Math.min(dt, 0.1); // 限制最大帧间隔,防止页面切换后台后瞬间跳过很多帧
if (shake > 0) shake = Math.max(0, shake - dtClamp*12); // 屏幕抖动随时间衰减
if (restartCD > 0) restartCD = Math.max(0, restartCD - dtClamp); // 重启冷却时间减少
if (gameState === STATE.PLAY) {
// 计分:累计存活时间并转换成分数
scoreAcc += dtClamp;
const prev = Math.floor(score);
score = scoreAcc * SCORE_RATE;
if (Math.floor(score) > prev && Math.floor(score)%10===0 && score>0) sfxScore(); // 每满 10 分播放一次得分音效
// 重力:玩家没有落地时,持续施加向下加速度
if (!onGround) {
playerVy += GRAVITY * dtClamp;
playerY += playerVy * dtClamp;
if (playerY >= GROUND_Y) {
playerY = GROUND_Y; playerVy = 0; onGround = true; // 落地后停止下落
}
}
// 障碍物生成与移动:每隔一段时间生成一个新障碍,并整体向左推进
spawnTimer -= dtClamp;
if (spawnTimer <= 0) {
spawn();
spawnTimer = SPAWN_MIN + Math.random()*(SPAWN_MAX-SPAWN_MIN);
}
for (const obs of obstacles) obs.x -= SPEED * dtClamp;
while (obstacles.length && obstacles[0].x < -60) obstacles.shift(); // 删除已经离开视口的障碍物,避免堆积
// 碰撞检测:遍历所有障碍,检查与玩家矩形是否重叠
const pBox = playerBox();
for (const obs of obstacles) {
if (hitTest(pBox, obs)) {
gameState = STATE.OVER; // 触发失败状态
overAlpha = 0; shake = 0.8; restartCD = 0.5; // 设置失败动画和重启冷却
sfxHit();
hintBar.style.opacity = '1';
hintBar.textContent = '点击任意处重新开始';
break; // 命中后即停止循环,避免重复判定
}
}
}
if (gameState === STATE.OVER && overAlpha < 1.5) overAlpha += dtClamp*2; // 游戏结束时逐渐显示遮罩层
}
// -------------------- 渲染 --------------------
function render() {
ctx.clearRect(0,0,WIDTH,HEIGHT); // 每帧先清空画布,避免残影
let sx=0, sy=0; // 震动偏移量,先置为 0
if (shake>0) { sx=(Math.random()-0.5)*shake*12; sy=(Math.random()-0.5)*shake*10; } // 随机生成小幅度抖动偏移
ctx.save();
ctx.translate(sx, sy); // 把画布整体偏移,制造抖动效果
drawBackground(); // 先画背景
drawGround(); // 再画地面
for (const obs of obstacles) drawCactus(obs); // 然后画所有障碍物
drawPlayer(playerY, !onGround || playerY<GROUND_Y); // 最后画玩家角色
ctx.restore();
drawUI(); // 画分数 UI
drawGameOver(); // 画结束画面
if (gameState === STATE.WAIT) {
ctx.fillStyle = '#111';
ctx.font = 'bold 22px "Courier New", monospace';
ctx.textAlign = 'center';
ctx.fillText('点击任意处开始', WIDTH/2, HEIGHT/2-10);
ctx.font = '14px "Courier New", monospace';
ctx.fillText('极简跑酷PARKOUR', WIDTH/2, HEIGHT/2+25);
}
}
// -------------------- 循环 --------------------
function loop(now) {
const dt = (now - lastTime) / 1000; // 计算两帧之间的时间差,单位为秒
lastTime = now; // 更新上一帧时间
if (dt>0 && dt<0.5) update(dt); // 过滤掉不合理的巨大时差,保证游戏稳定
render(); // 每帧都重新绘制画面
requestAnimationFrame(loop); // 请求下一帧,形成动画循环
}
// -------------------- 输入 --------------------
function jumpAction() {
if (gameState === STATE.WAIT) {
reset();
if (onGround) { playerVy = JUMP_VEL; onGround = false; sfxJump(); }
return; // 如果等待状态时按下,执行一次启始跳跃并立即进入游戏
}
if (gameState === STATE.PLAY && onGround) {
playerVy = JUMP_VEL; onGround = false; sfxJump(); // 仅在地面上才跳跃,避免二段跳
}
if (gameState === STATE.OVER && restartCD <= 0) {
reset(); // 结束后点击空格重新开始
}
}
window.addEventListener('keydown', e => {
if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') {
e.preventDefault(); // 阻止页面默认滚动和方向键行为
jumpAction();
}
});
canvas.addEventListener('click', e => { e.preventDefault(); jumpAction(); }); // 点击画布也触发跳跃
canvas.addEventListener('touchstart', e => { e.preventDefault(); jumpAction(); }, {passive: false}); // 移动端触摸触发
canvas.addEventListener('dblclick', e => e.preventDefault()); // 防止双击时页面进行缩放
// 初始画面:页面首次加载时显示等待提示,不直接开始
function drawInit() {
ctx.clearRect(0,0,WIDTH,HEIGHT);
drawBackground();
drawGround();
drawPlayer(GROUND_Y, false);
drawUI();
ctx.fillStyle = '#111';
ctx.font = 'bold 22px "Courier New", monospace';
ctx.textAlign = 'center';
ctx.fillText('点击屏幕任意处开始', WIDTH/2, HEIGHT/2-10);
ctx.font = '14px "Courier New", monospace';
ctx.fillText('极简跑酷JUMP', WIDTH/2, HEIGHT/2+25);
}
drawInit(); // 初始化界面绘制
hintBar.style.opacity = '1'; // 显示提示栏
hintBar.textContent = '[ 空格 / 点击 ] 跳跃'; // 设置提示文案
lastTime = performance.now(); // 记录开始时间,方便循环计时
requestAnimationFrame(loop); // 启动主循环,页面开始更新渲染
})();
</script>
</body>
</html>Game Source: 🏃极简线条跑酷PARKOUR
Creator: RocketTiger92
Libraries: none
Complexity: complex (484 lines, 25.0 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-rockettiger92-mspxnsc0" to link back to the original. Then publish at arcadelab.ai/publish.