忍者闯关

Published on
/
/趣玩前端
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>忍者闯关</title>
    <style>
      html,
      body {
        height: 100%;
        margin: 0;
      }

      body {
        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        cursor: pointer;
      }

      .container {
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100%;
      }

      #score {
        position: absolute;
        top: 30px;
        right: 30px;
        font-size: 2em;
        font-weight: 900;
      }

      #introduction {
        width: 200px;
        height: 150px;
        position: absolute;
        font-weight: 600;
        font-size: 0.8em;
        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        text-align: center;
        transition: opacity 2s;
      }

      #restart {
        width: 120px;
        height: 120px;
        position: absolute;
        border-radius: 50%;
        color: white;
        background-color: red;
        border: none;
        font-weight: 700;
        font-size: 1.2em;
        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        display: none;
        cursor: pointer;
      }

      #perfect {
        position: absolute;
        opacity: 0;
        transition: opacity 2s;
      }
    </style>
  </head>
  <body>
    <!-- partial:index.partial.html -->
    <div class="container">
      <div id="score"></div>
      <canvas id="game" width="375" height="375"></canvas>
      <div id="introduction">按住鼠标可伸出一根棍子</div>
      <div id="perfect">双倍积分</div>
      <button id="restart">重新开始</button>
    </div>

    <script>
      // Extend the base functionality of JavaScript
      Array.prototype.last = function () {
        return this[this.length - 1];
      };

      // A sinus function that acceps degrees instead of radians
      Math.sinus = function (degree) {
        return Math.sin((degree / 180) * Math.PI);
      };

      // Game data
      let phase = 'waiting'; // waiting | stretching | turning | walking | transitioning | falling
      let lastTimestamp; // The timestamp of the previous requestAnimationFrame cycle

      let heroX; // Changes when moving forward
      let heroY; // Only changes when falling
      let sceneOffset; // Moves the whole game

      let platforms = [];
      let sticks = [];
      let trees = [];

      // Todo: Save high score to localStorage (?)

      let score = 0;

      // Configuration
      const canvasWidth = 375;
      const canvasHeight = 375;
      const platformHeight = 100;
      const heroDistanceFromEdge = 10; // While waiting
      const paddingX = 100; // The waiting position of the hero in from the original canvas size
      const perfectAreaSize = 10;

      // The background moves slower than the hero
      const backgroundSpeedMultiplier = 0.2;

      const hill1BaseHeight = 100;
      const hill1Amplitude = 10;
      const hill1Stretch = 1;
      const hill2BaseHeight = 70;
      const hill2Amplitude = 20;
      const hill2Stretch = 0.5;

      const stretchingSpeed = 4; // Milliseconds it takes to draw a pixel
      const turningSpeed = 4; // Milliseconds it takes to turn a degree
      const walkingSpeed = 4;
      const transitioningSpeed = 2;
      const fallingSpeed = 2;

      const heroWidth = 17; // 24
      const heroHeight = 30; // 40

      const canvas = document.getElementById('game');
      canvas.width = window.innerWidth; // Make the Canvas full screen
      canvas.height = window.innerHeight;

      const ctx = canvas.getContext('2d');

      const introductionElement = document.getElementById('introduction');
      const perfectElement = document.getElementById('perfect');
      const restartButton = document.getElementById('restart');
      const scoreElement = document.getElementById('score');

      // Initialize layout
      resetGame();

      // Resets game variables and layouts but does not start the game (game starts on keypress)
      function resetGame() {
        // Reset game progress
        phase = 'waiting';
        lastTimestamp = undefined;
        sceneOffset = 0;
        score = 0;

        introductionElement.style.opacity = 1;
        perfectElement.style.opacity = 0;
        restartButton.style.display = 'none';
        scoreElement.innerText = score;

        // The first platform is always the same
        // x + w has to match paddingX
        platforms = [{ x: 50, w: 50 }];
        generatePlatform();
        generatePlatform();
        generatePlatform();
        generatePlatform();

        sticks = [
          { x: platforms[0].x + platforms[0].w, length: 0, rotation: 0 },
        ];

        trees = [];
        generateTree();
        generateTree();
        generateTree();
        generateTree();
        generateTree();
        generateTree();
        generateTree();
        generateTree();
        generateTree();
        generateTree();

        heroX = platforms[0].x + platforms[0].w - heroDistanceFromEdge;
        heroY = 0;

        draw();
      }

      function generateTree() {
        const minimumGap = 30;
        const maximumGap = 150;

        // X coordinate of the right edge of the furthest tree
        const lastTree = trees[trees.length - 1];
        let furthestX = lastTree ? lastTree.x : 0;

        const x =
          furthestX +
          minimumGap +
          Math.floor(Math.random() * (maximumGap - minimumGap));

        const treeColors = ['#6D8821', '#8FAC34', '#98B333'];
        const color = treeColors[Math.floor(Math.random() * 3)];

        trees.push({ x, color });
      }

      function generatePlatform() {
        const minimumGap = 40;
        const maximumGap = 200;
        const minimumWidth = 20;
        const maximumWidth = 100;

        // X coordinate of the right edge of the furthest platform
        const lastPlatform = platforms[platforms.length - 1];
        let furthestX = lastPlatform.x + lastPlatform.w;

        const x =
          furthestX +
          minimumGap +
          Math.floor(Math.random() * (maximumGap - minimumGap));
        const w =
          minimumWidth +
          Math.floor(Math.random() * (maximumWidth - minimumWidth));

        platforms.push({ x, w });
      }

      resetGame();

      // If space was pressed restart the game
      window.addEventListener('keydown', function (event) {
        if (event.key == ' ') {
          event.preventDefault();
          resetGame();
          return;
        }
      });

      window.addEventListener('mousedown', function (event) {
        if (phase == 'waiting') {
          lastTimestamp = undefined;
          introductionElement.style.opacity = 0;
          phase = 'stretching';
          window.requestAnimationFrame(animate);
        }
      });

      window.addEventListener('mouseup', function (event) {
        if (phase == 'stretching') {
          phase = 'turning';
        }
      });

      window.addEventListener('resize', function (event) {
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
        draw();
      });

      window.requestAnimationFrame(animate);

      // The main game loop
      function animate(timestamp) {
        if (!lastTimestamp) {
          lastTimestamp = timestamp;
          window.requestAnimationFrame(animate);
          return;
        }

        switch (phase) {
          case 'waiting':
            return; // Stop the loop
          case 'stretching': {
            sticks.last().length +=
              (timestamp - lastTimestamp) / stretchingSpeed;
            break;
          }
          case 'turning': {
            sticks.last().rotation +=
              (timestamp - lastTimestamp) / turningSpeed;

            if (sticks.last().rotation > 90) {
              sticks.last().rotation = 90;

              const [nextPlatform, perfectHit] = thePlatformTheStickHits();
              if (nextPlatform) {
                // Increase score
                score += perfectHit ? 2 : 1;
                scoreElement.innerText = score;

                if (perfectHit) {
                  perfectElement.style.opacity = 1;
                  setTimeout(() => (perfectElement.style.opacity = 0), 1000);
                }

                generatePlatform();
                generateTree();
                generateTree();
              }

              phase = 'walking';
            }
            break;
          }
          case 'walking': {
            heroX += (timestamp - lastTimestamp) / walkingSpeed;

            const [nextPlatform] = thePlatformTheStickHits();
            if (nextPlatform) {
              // If hero will reach another platform then limit it's position at it's edge
              const maxHeroX =
                nextPlatform.x + nextPlatform.w - heroDistanceFromEdge;
              if (heroX > maxHeroX) {
                heroX = maxHeroX;
                phase = 'transitioning';
              }
            } else {
              // If hero won't reach another platform then limit it's position at the end of the pole
              const maxHeroX =
                sticks.last().x + sticks.last().length + heroWidth;
              if (heroX > maxHeroX) {
                heroX = maxHeroX;
                phase = 'falling';
              }
            }
            break;
          }
          case 'transitioning': {
            sceneOffset += (timestamp - lastTimestamp) / transitioningSpeed;

            const [nextPlatform] = thePlatformTheStickHits();
            if (sceneOffset > nextPlatform.x + nextPlatform.w - paddingX) {
              // Add the next step
              sticks.push({
                x: nextPlatform.x + nextPlatform.w,
                length: 0,
                rotation: 0,
              });
              phase = 'waiting';
            }
            break;
          }
          case 'falling': {
            if (sticks.last().rotation < 180)
              sticks.last().rotation +=
                (timestamp - lastTimestamp) / turningSpeed;

            heroY += (timestamp - lastTimestamp) / fallingSpeed;
            const maxHeroY =
              platformHeight + 100 + (window.innerHeight - canvasHeight) / 2;
            if (heroY > maxHeroY) {
              restartButton.style.display = 'block';
              return;
            }
            break;
          }
          default:
            throw Error('Wrong phase');
        }

        draw();
        window.requestAnimationFrame(animate);

        lastTimestamp = timestamp;
      }

      // Returns the platform the stick hit (if it didn't hit any stick then return undefined)
      function thePlatformTheStickHits() {
        if (sticks.last().rotation != 90)
          throw Error(`Stick is ${sticks.last().rotation}°`);
        const stickFarX = sticks.last().x + sticks.last().length;

        const platformTheStickHits = platforms.find(
          (platform) =>
            platform.x < stickFarX && stickFarX < platform.x + platform.w
        );

        // If the stick hits the perfect area
        if (
          platformTheStickHits &&
          platformTheStickHits.x +
            platformTheStickHits.w / 2 -
            perfectAreaSize / 2 <
            stickFarX &&
          stickFarX <
            platformTheStickHits.x +
              platformTheStickHits.w / 2 +
              perfectAreaSize / 2
        )
          return [platformTheStickHits, true];

        return [platformTheStickHits, false];
      }

      function draw() {
        ctx.save();
        ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);

        drawBackground();

        // Center main canvas area to the middle of the screen
        ctx.translate(
          (window.innerWidth - canvasWidth) / 2 - sceneOffset,
          (window.innerHeight - canvasHeight) / 2
        );

        // Draw scene
        drawPlatforms();
        drawHero();
        drawSticks();

        // Restore transformation
        ctx.restore();
      }

      restartButton.addEventListener('click', function (event) {
        event.preventDefault();
        resetGame();
        restartButton.style.display = 'none';
      });

      function drawPlatforms() {
        platforms.forEach(({ x, w }) => {
          // Draw platform
          ctx.fillStyle = 'black';
          ctx.fillRect(
            x,
            canvasHeight - platformHeight,
            w,
            platformHeight + (window.innerHeight - canvasHeight) / 2
          );

          // Draw perfect area only if hero did not yet reach the platform
          if (sticks.last().x < x) {
            ctx.fillStyle = 'red';
            ctx.fillRect(
              x + w / 2 - perfectAreaSize / 2,
              canvasHeight - platformHeight,
              perfectAreaSize,
              perfectAreaSize
            );
          }
        });
      }

      function drawHero() {
        ctx.save();
        ctx.fillStyle = 'black';
        ctx.translate(
          heroX - heroWidth / 2,
          heroY + canvasHeight - platformHeight - heroHeight / 2
        );

        // Body
        drawRoundedRect(
          -heroWidth / 2,
          -heroHeight / 2,
          heroWidth,
          heroHeight - 4,
          5
        );

        // Legs
        const legDistance = 5;
        ctx.beginPath();
        ctx.arc(legDistance, 11.5, 3, 0, Math.PI * 2, false);
        ctx.fill();
        ctx.beginPath();
        ctx.arc(-legDistance, 11.5, 3, 0, Math.PI * 2, false);
        ctx.fill();

        // Eye
        ctx.beginPath();
        ctx.fillStyle = 'white';
        ctx.arc(5, -7, 3, 0, Math.PI * 2, false);
        ctx.fill();

        // Band
        ctx.fillStyle = 'red';
        ctx.fillRect(-heroWidth / 2 - 1, -12, heroWidth + 2, 4.5);
        ctx.beginPath();
        ctx.moveTo(-9, -14.5);
        ctx.lineTo(-17, -18.5);
        ctx.lineTo(-14, -8.5);
        ctx.fill();
        ctx.beginPath();
        ctx.moveTo(-10, -10.5);
        ctx.lineTo(-15, -3.5);
        ctx.lineTo(-5, -7);
        ctx.fill();

        ctx.restore();
      }

      function drawRoundedRect(x, y, width, height, radius) {
        ctx.beginPath();
        ctx.moveTo(x, y + radius);
        ctx.lineTo(x, y + height - radius);
        ctx.arcTo(x, y + height, x + radius, y + height, radius);
        ctx.lineTo(x + width - radius, y + height);
        ctx.arcTo(
          x + width,
          y + height,
          x + width,
          y + height - radius,
          radius
        );
        ctx.lineTo(x + width, y + radius);
        ctx.arcTo(x + width, y, x + width - radius, y, radius);
        ctx.lineTo(x + radius, y);
        ctx.arcTo(x, y, x, y + radius, radius);
        ctx.fill();
      }

      function drawSticks() {
        sticks.forEach((stick) => {
          ctx.save();

          // Move the anchor point to the start of the stick and rotate
          ctx.translate(stick.x, canvasHeight - platformHeight);
          ctx.rotate((Math.PI / 180) * stick.rotation);

          // Draw stick
          ctx.beginPath();
          ctx.lineWidth = 2;
          ctx.moveTo(0, 0);
          ctx.lineTo(0, -stick.length);
          ctx.stroke();

          // Restore transformations
          ctx.restore();
        });
      }

      function drawBackground() {
        // Draw sky
        var gradient = ctx.createLinearGradient(0, 0, 0, window.innerHeight);
        gradient.addColorStop(0, '#BBD691');
        gradient.addColorStop(1, '#FEF1E1');
        ctx.fillStyle = gradient;
        ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);

        // Draw hills
        drawHill(hill1BaseHeight, hill1Amplitude, hill1Stretch, '#95C629');
        drawHill(hill2BaseHeight, hill2Amplitude, hill2Stretch, '#659F1C');

        // Draw trees
        trees.forEach((tree) => drawTree(tree.x, tree.color));
      }

      // A hill is a shape under a stretched out sinus wave
      function drawHill(baseHeight, amplitude, stretch, color) {
        ctx.beginPath();
        ctx.moveTo(0, window.innerHeight);
        ctx.lineTo(0, getHillY(0, baseHeight, amplitude, stretch));
        for (let i = 0; i < window.innerWidth; i++) {
          ctx.lineTo(i, getHillY(i, baseHeight, amplitude, stretch));
        }
        ctx.lineTo(window.innerWidth, window.innerHeight);
        ctx.fillStyle = color;
        ctx.fill();
      }

      function drawTree(x, color) {
        ctx.save();
        ctx.translate(
          (-sceneOffset * backgroundSpeedMultiplier + x) * hill1Stretch,
          getTreeY(x, hill1BaseHeight, hill1Amplitude)
        );

        const treeTrunkHeight = 5;
        const treeTrunkWidth = 2;
        const treeCrownHeight = 25;
        const treeCrownWidth = 10;

        // Draw trunk
        ctx.fillStyle = '#7D833C';
        ctx.fillRect(
          -treeTrunkWidth / 2,
          -treeTrunkHeight,
          treeTrunkWidth,
          treeTrunkHeight
        );

        // Draw crown
        ctx.beginPath();
        ctx.moveTo(-treeCrownWidth / 2, -treeTrunkHeight);
        ctx.lineTo(0, -(treeTrunkHeight + treeCrownHeight));
        ctx.lineTo(treeCrownWidth / 2, -treeTrunkHeight);
        ctx.fillStyle = color;
        ctx.fill();

        ctx.restore();
      }

      function getHillY(windowX, baseHeight, amplitude, stretch) {
        const sineBaseY = window.innerHeight - baseHeight;
        return (
          Math.sinus(
            (sceneOffset * backgroundSpeedMultiplier + windowX) * stretch
          ) *
            amplitude +
          sineBaseY
        );
      }

      function getTreeY(x, baseHeight, amplitude) {
        const sineBaseY = window.innerHeight - baseHeight;
        return Math.sinus(x) * amplitude + sineBaseY;
      }
    </script>
  </body>
</html>