HTML5 Canvas 相机/视口 - 实际怎么做?

新手上路,请多包涵

我敢肯定这之前已经解决了 1000 次:我有一个大小为 960*560 的画布和一个大小为 5000*3000 的房间,其中始终只应绘制 960*560,具体取决于玩家所在的位置。玩家应该总是在中间,但是当靠近边界时——那么最好的视角应该被计算出来)。玩家可以使用 WASD 或箭头键完全自由移动。所有的物体都应该自己移动——而不是我移动除了玩家之外的所有其他东西来创造玩家移动的错觉。

我现在发现了这两个问题:

HTML5 - 为画布创建视口是可行的,但仅适用于此类游戏,我无法为我的游戏重现代码。

更改 html5 画布的视图“中心” 似乎更有前途且性能更好,但我只理解它是为了相对于播放器正确绘制所有其他对象,而不是如何相对于播放器滚动画布视口,这是我想要的首先当然要实现。

我的代码(简化 - 游戏逻辑是分开的):

 var canvas = document.getElementById("game");
canvas.tabIndex = 0;
canvas.focus();
var cc = canvas.getContext("2d");

// Define viewports for scrolling inside the canvas

/* Viewport x position */   view_xview = 0;
/* Viewport y position */   view_yview = 0;
/* Viewport width */        view_wview = 960;
/* Viewport height */       view_hview = 560;
/* Sector width */          room_width = 5000;
/* Sector height */         room_height = 3000;

canvas.width = view_wview;
canvas.height = view_hview;

function draw()
{
    clear();
    requestAnimFrame(draw);

    // World's end and viewport
    if (player.x < 20) player.x = 20;
    if (player.y < 20) player.y = 20;
    if (player.x > room_width-20) player.x = room_width-20;
    if (player.y > room_height-20) player.y = room_height-20;

    if (player.x > view_wview/2) ... ?
    if (player.y > view_hview/2) ... ?
}

我试图让它工作的方式感觉完全错误,我什至不知道我是如何尝试的……有什么想法吗?您如何看待 context.transform-thing?

我希望你能理解我的描述,并且有人有想法。亲切的问候

原文由 user2337969 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 927
2 个回答

jsfiddle.net 进行 现场演示

该演示演示了真实游戏场景中视口的使用。使用箭头键将玩家移动到房间上方。大房间是使用矩形动态生成的,结果保存到图像中。

请注意,玩家总是在中间,除非靠近边界(如您所愿)。


现在,我将尝试解释代码的主要部分,至少是那些光看就更难理解的部分。


使用drawImage根据视口位置绘制大图

drawImage 方法的一个变体有八个新参数。我们可以使用此方法对源图像的部分进行切片并将它们绘制到画布上。

drawImage(图像,sx,sy,sWidth,sHeight,dx,dy,dWidth,dHeight)

与其他变体一样,第一个参数图像是对图像对象的引用或对不同画布元素的引用。对于其他八个参数,最好查看下图。前四个参数定义切片在源图像上的位置和大小。最后四个参数定义目标画布上的位置和大小。

画布绘制图像

字体: https ://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Canvas_tutorial/Using_images

它在演示中是如何工作的:

我们有一个代表房间的大图像,我们只想在画布上显示视口内的部分。裁剪位置(sx,sy)与相机(xView,yView)的位置相同,裁剪尺寸与视口(画布)相同,因此 sWidth=canvas.widthsHeight=canvas.height

我们需要注意裁剪尺寸,因为 drawImage 如果裁剪位置或基于位置的裁剪尺寸无效,则不会在画布上绘制任何内容。这就是为什么我们需要下面的 if 部分。

 var sx, sy, dx, dy;
var sWidth, sHeight, dWidth, dHeight;

// offset point to crop the image
sx = xView;
sy = yView;

// dimensions of cropped image
sWidth =  context.canvas.width;
sHeight = context.canvas.height;

// if cropped image is smaller than canvas we need to change the source dimensions
if(image.width - sx < sWidth){
    sWidth = image.width - sx;
}
if(image.height - sy < sHeight){
    sHeight = image.height - sy;
}

// location on canvas to draw the croped image
dx = 0;
dy = 0;
// match destination with source to not scale the image
dWidth = sWidth;
dHeight = sHeight;

// draw the cropped image
context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);


绘制与视口相关的游戏对象

编写游戏时,最好将游戏中每个对象的逻辑和渲染分开。所以在演示中我们有 updatedraw 函数。 update 方法更改对象状态,如在“游戏世界”中的位置、应用物理、动画状态等 draw 方法实际渲染对象并考虑视口正确渲染它,对象需要知道渲染上下文和视口属性。

请注意,游戏对象会根据游戏世界的位置进行更新。这意味着对象的 (x,y) 位置是世界中的位置。尽管如此,由于视口在变化,因此需要正确渲染对象,并且渲染位置将不同于世界位置。

转换很简单:

物体在世界(房间)中的位置: (x, y)

视口位置: (xView, yView)

渲染位置(x-xView, y-yView)

这适用于所有类型的坐标,甚至是负坐标。


游戏相机

我们的游戏对象有一个单独的更新方法。在 Demo 实现中,相机被视为游戏对象,也有单独的更新方法。

相机对象持有视口的左上角位置 (xView, yView) ,一个要跟随的对象,一个代表视口的矩形,一个代表游戏世界边界的矩形和玩家之前可以到达的每个边界的最小距离相机开始移动 (xDeadZone, yDeadZone)。我们还定义了相机的自由度(轴)。对于顶视图风格的游戏,如角色扮演游戏,允许相机在 x(水平)和 y(垂直)轴上移动。

为了让玩家保持在视口的中间,我们将每个轴的死区设置为与画布的中心会聚。查看代码中的以下函数:

camera.follow(播放器,canvas.width/2,canvas.height/2)

注意:请参阅下面的更新部分,因为当地图(房间)的任何维度小于画布时,这不会产生预期的行为。


世界的极限

由于包括相机在内的每个对象都有自己的更新功能,因此很容易检查游戏世界的边界。只记得将阻止移动的代码放在更新函数的最后。


示范

查看完整代码并自己尝试。大部分代码都有注释来指导您完成。我假设您了解 Javascript 的基础知识以及如何使用原型(有时我使用术语“类”来表示原型对象只是因为它与 Java 等语言中的类具有相似的行为)。

现场演示

完整代码:

 <!DOCTYPE HTML>
<html>
<body>
<canvas id="gameCanvas" width=400 height=400 />
<script>
// wrapper for our game "classes", "methods" and "objects"
window.Game = {};

// wrapper for "class" Rectangle
(function() {
  function Rectangle(left, top, width, height) {
    this.left = left || 0;
    this.top = top || 0;
    this.width = width || 0;
    this.height = height || 0;
    this.right = this.left + this.width;
    this.bottom = this.top + this.height;
  }

  Rectangle.prototype.set = function(left, top, /*optional*/ width, /*optional*/ height) {
    this.left = left;
    this.top = top;
    this.width = width || this.width;
    this.height = height || this.height
    this.right = (this.left + this.width);
    this.bottom = (this.top + this.height);
  }

  Rectangle.prototype.within = function(r) {
    return (r.left <= this.left &&
      r.right >= this.right &&
      r.top <= this.top &&
      r.bottom >= this.bottom);
  }

  Rectangle.prototype.overlaps = function(r) {
    return (this.left < r.right &&
      r.left < this.right &&
      this.top < r.bottom &&
      r.top < this.bottom);
  }

  // add "class" Rectangle to our Game object
  Game.Rectangle = Rectangle;
})();

// wrapper for "class" Camera (avoid global objects)
(function() {

  // possibles axis to move the camera
  var AXIS = {
    NONE: 1,
    HORIZONTAL: 2,
    VERTICAL: 3,
    BOTH: 4
  };

  // Camera constructor
  function Camera(xView, yView, viewportWidth, viewportHeight, worldWidth, worldHeight) {
    // position of camera (left-top coordinate)
    this.xView = xView || 0;
    this.yView = yView || 0;

    // distance from followed object to border before camera starts move
    this.xDeadZone = 0; // min distance to horizontal borders
    this.yDeadZone = 0; // min distance to vertical borders

    // viewport dimensions
    this.wView = viewportWidth;
    this.hView = viewportHeight;

    // allow camera to move in vertical and horizontal axis
    this.axis = AXIS.BOTH;

    // object that should be followed
    this.followed = null;

    // rectangle that represents the viewport
    this.viewportRect = new Game.Rectangle(this.xView, this.yView, this.wView, this.hView);

    // rectangle that represents the world's boundary (room's boundary)
    this.worldRect = new Game.Rectangle(0, 0, worldWidth, worldHeight);

  }

  // gameObject needs to have "x" and "y" properties (as world(or room) position)
  Camera.prototype.follow = function(gameObject, xDeadZone, yDeadZone) {
    this.followed = gameObject;
    this.xDeadZone = xDeadZone;
    this.yDeadZone = yDeadZone;
  }

  Camera.prototype.update = function() {
    // keep following the player (or other desired object)
    if (this.followed != null) {
      if (this.axis == AXIS.HORIZONTAL || this.axis == AXIS.BOTH) {
        // moves camera on horizontal axis based on followed object position
        if (this.followed.x - this.xView + this.xDeadZone > this.wView)
          this.xView = this.followed.x - (this.wView - this.xDeadZone);
        else if (this.followed.x - this.xDeadZone < this.xView)
          this.xView = this.followed.x - this.xDeadZone;

      }
      if (this.axis == AXIS.VERTICAL || this.axis == AXIS.BOTH) {
        // moves camera on vertical axis based on followed object position
        if (this.followed.y - this.yView + this.yDeadZone > this.hView)
          this.yView = this.followed.y - (this.hView - this.yDeadZone);
        else if (this.followed.y - this.yDeadZone < this.yView)
          this.yView = this.followed.y - this.yDeadZone;
      }

    }

    // update viewportRect
    this.viewportRect.set(this.xView, this.yView);

    // don't let camera leaves the world's boundary
    if (!this.viewportRect.within(this.worldRect)) {
      if (this.viewportRect.left < this.worldRect.left)
        this.xView = this.worldRect.left;
      if (this.viewportRect.top < this.worldRect.top)
        this.yView = this.worldRect.top;
      if (this.viewportRect.right > this.worldRect.right)
        this.xView = this.worldRect.right - this.wView;
      if (this.viewportRect.bottom > this.worldRect.bottom)
        this.yView = this.worldRect.bottom - this.hView;
    }

  }

  // add "class" Camera to our Game object
  Game.Camera = Camera;

})();

// wrapper for "class" Player
(function() {
  function Player(x, y) {
    // (x, y) = center of object
    // ATTENTION:
    // it represents the player position on the world(room), not the canvas position
    this.x = x;
    this.y = y;

    // move speed in pixels per second
    this.speed = 200;

    // render properties
    this.width = 50;
    this.height = 50;
  }

  Player.prototype.update = function(step, worldWidth, worldHeight) {
    // parameter step is the time between frames ( in seconds )

    // check controls and move the player accordingly
    if (Game.controls.left)
      this.x -= this.speed * step;
    if (Game.controls.up)
      this.y -= this.speed * step;
    if (Game.controls.right)
      this.x += this.speed * step;
    if (Game.controls.down)
      this.y += this.speed * step;

    // don't let player leaves the world's boundary
    if (this.x - this.width / 2 < 0) {
      this.x = this.width / 2;
    }
    if (this.y - this.height / 2 < 0) {
      this.y = this.height / 2;
    }
    if (this.x + this.width / 2 > worldWidth) {
      this.x = worldWidth - this.width / 2;
    }
    if (this.y + this.height / 2 > worldHeight) {
      this.y = worldHeight - this.height / 2;
    }
  }

  Player.prototype.draw = function(context, xView, yView) {
    // draw a simple rectangle shape as our player model
    context.save();
    context.fillStyle = "black";
    // before draw we need to convert player world's position to canvas position
    context.fillRect((this.x - this.width / 2) - xView, (this.y - this.height / 2) - yView, this.width, this.height);
    context.restore();
  }

  // add "class" Player to our Game object
  Game.Player = Player;

})();

// wrapper for "class" Map
(function() {
  function Map(width, height) {
    // map dimensions
    this.width = width;
    this.height = height;

    // map texture
    this.image = null;
  }

  // creates a prodedural generated map (you can use an image instead)
  Map.prototype.generate = function() {
    var ctx = document.createElement("canvas").getContext("2d");
    ctx.canvas.width = this.width;
    ctx.canvas.height = this.height;

    var rows = ~~(this.width / 44) + 1;
    var columns = ~~(this.height / 44) + 1;

    var color = "red";
    ctx.save();
    ctx.fillStyle = "red";
    for (var x = 0, i = 0; i < rows; x += 44, i++) {
      ctx.beginPath();
      for (var y = 0, j = 0; j < columns; y += 44, j++) {
        ctx.rect(x, y, 40, 40);
      }
      color = (color == "red" ? "blue" : "red");
      ctx.fillStyle = color;
      ctx.fill();
      ctx.closePath();
    }
    ctx.restore();

    // store the generate map as this image texture
    this.image = new Image();
    this.image.src = ctx.canvas.toDataURL("image/png");

    // clear context
    ctx = null;
  }

  // draw the map adjusted to camera
  Map.prototype.draw = function(context, xView, yView) {
    // easiest way: draw the entire map changing only the destination coordinate in canvas
    // canvas will cull the image by itself (no performance gaps -> in hardware accelerated environments, at least)
    /*context.drawImage(this.image, 0, 0, this.image.width, this.image.height, -xView, -yView, this.image.width, this.image.height);*/

    // didactic way ( "s" is for "source" and "d" is for "destination" in the variable names):

    var sx, sy, dx, dy;
    var sWidth, sHeight, dWidth, dHeight;

    // offset point to crop the image
    sx = xView;
    sy = yView;

    // dimensions of cropped image
    sWidth = context.canvas.width;
    sHeight = context.canvas.height;

    // if cropped image is smaller than canvas we need to change the source dimensions
    if (this.image.width - sx < sWidth) {
      sWidth = this.image.width - sx;
    }
    if (this.image.height - sy < sHeight) {
      sHeight = this.image.height - sy;
    }

    // location on canvas to draw the croped image
    dx = 0;
    dy = 0;
    // match destination with source to not scale the image
    dWidth = sWidth;
    dHeight = sHeight;

    context.drawImage(this.image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
  }

  // add "class" Map to our Game object
  Game.Map = Map;

})();

// Game Script
(function() {
  // prepaire our game canvas
  var canvas = document.getElementById("gameCanvas");
  var context = canvas.getContext("2d");

  // game settings:
  var FPS = 30;
  var INTERVAL = 1000 / FPS; // milliseconds
  var STEP = INTERVAL / 1000 // seconds

  // setup an object that represents the room
  var room = {
    width: 500,
    height: 300,
    map: new Game.Map(500, 300)
  };

  // generate a large image texture for the room
  room.map.generate();

  // setup player
  var player = new Game.Player(50, 50);

  // Old camera setup. It not works with maps smaller than canvas. Keeping the code deactivated here as reference.
  /* var camera = new Game.Camera(0, 0, canvas.width, canvas.height, room.width, room.height);*/
  /* camera.follow(player, canvas.width / 2, canvas.height / 2); */

  // Set the right viewport size for the camera
  var vWidth = Math.min(room.width, canvas.width);
  var vHeight = Math.min(room.height, canvas.height);

  // Setup the camera
  var camera = new Game.Camera(0, 0, vWidth, vHeight, room.width, room.height);
  camera.follow(player, vWidth / 2, vHeight / 2);

  // Game update function
  var update = function() {
    player.update(STEP, room.width, room.height);
    camera.update();
  }

  // Game draw function
  var draw = function() {
    // clear the entire canvas
    context.clearRect(0, 0, canvas.width, canvas.height);

    // redraw all objects
    room.map.draw(context, camera.xView, camera.yView);
    player.draw(context, camera.xView, camera.yView);
  }

  // Game Loop
  var gameLoop = function() {
    update();
    draw();
  }

  // <-- configure play/pause capabilities:

  // Using setInterval instead of requestAnimationFrame for better cross browser support,
  // but it's easy to change to a requestAnimationFrame polyfill.

  var runningId = -1;

  Game.play = function() {
    if (runningId == -1) {
      runningId = setInterval(function() {
        gameLoop();
      }, INTERVAL);
      console.log("play");
    }
  }

  Game.togglePause = function() {
    if (runningId == -1) {
      Game.play();
    } else {
      clearInterval(runningId);
      runningId = -1;
      console.log("paused");
    }
  }

  // -->

})();

// <-- configure Game controls:

Game.controls = {
  left: false,
  up: false,
  right: false,
  down: false,
};

window.addEventListener("keydown", function(e) {
  switch (e.keyCode) {
    case 37: // left arrow
      Game.controls.left = true;
      break;
    case 38: // up arrow
      Game.controls.up = true;
      break;
    case 39: // right arrow
      Game.controls.right = true;
      break;
    case 40: // down arrow
      Game.controls.down = true;
      break;
  }
}, false);

window.addEventListener("keyup", function(e) {
  switch (e.keyCode) {
    case 37: // left arrow
      Game.controls.left = false;
      break;
    case 38: // up arrow
      Game.controls.up = false;
      break;
    case 39: // right arrow
      Game.controls.right = false;
      break;
    case 40: // down arrow
      Game.controls.down = false;
      break;
    case 80: // key P pauses the game
      Game.togglePause();
      break;
  }
}, false);

// -->

// start the game when page is loaded
window.onload = function() {
  Game.play();
}

</script>
</body>
</html>

更新

如果地图(房间)的宽度和/或高度小于画布,则之前的代码将无法正常工作。要解决此问题,请在游戏脚本中按如下方式设置相机:

 // Set the right viewport size for the camera
var vWidth = Math.min(room.width, canvas.width);
var vHeight = Math.min(room.height, canvas.height);

var camera = new Game.Camera(0, 0, vWidth, vHeight, room.width, room.height);
camera.follow(player, vWidth / 2, vHeight / 2);

您只需要告诉相机构造函数视口将是地图(房间)或画布之间的最小值。由于我们希望玩家居中并绑定到该视口,因此 camera.follow 函数也必须更新。


请随时报告任何错误或添加建议。

原文由 Gustavo Carvalho 发布,翻译遵循 CC BY-SA 4.0 许可协议

这是一个简单的例子,我们将相机位置固定在游戏世界的边界上。这允许相机在游戏世界中移动,并且永远不会在您指定的边界之外显示任何空白空间。

 const worldBounds = {minX:-100,maxX:100,minY:-100,maxY:100};
function draw() {
    ctx.setTransform(1,0,0,1,0,0);//reset the transform matrix as it is cumulative
    ctx.clearRect(0, 0, canvas.width, canvas.height);//clear the viewport AFTER the matrix is reset

    // update the player position
    movePlayer();

    // player is clamped to the world boundaries - don't let the player leave
    player.x = clamp(player.x, worldBounds.minX, worldBounds.maxX);
    player.y = clamp(player.y, worldBounds.minY, worldBounds.maxY);

    // center the camera around the player,
    // but clamp the edges of the camera view to the world bounds.
    const camX = clamp(player.x - canvas.width/2, worldBounds.minX, worldBounds.maxX - canvas.width);
    const camY = clamp(player.y - canvas.height/2, worldBounds.minY, worldBounds.maxY - canvas.height);

    ctx.translate(-camX, -camY);

    //Draw everything
}

clamp 只是确保给定的值始终在指定的最小/最大范围之间:

 // clamp(10, 20, 30) - output: 20
// clamp(40, 20, 30) - output: 30
// clamp(25, 20, 30) - output: 25
function clamp(value, min, max){
    if(value < min) return min;
    else if(value > max) return max;
    return value;
}

以@dKorosec 的示例为基础 - 使用箭头键移动: Fiddle

原文由 Colton 发布,翻译遵循 CC BY-SA 4.0 许可协议

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题