59

我确信这已经解决了 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?

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

4

7 回答 7

103

jsfiddle.net上的现场演示

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

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


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


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

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

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

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

画布绘制图像

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

它在演示中的工作原理:

我们有一个代表房间的大图像,我们只想在画布上显示视口内的部分。裁剪位置 (sx, sy) 与相机 (xView, yView) 的位置相同,裁剪尺寸与视口 (canvas) 相同,所以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 实现中,相机被视为一个游戏对象,也有一个单独的更新方法。

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

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

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功能也必须更新。


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

于 2013-06-04T19:49:26.913 回答
33

接受的答案中的代码有点多。就这么简单:

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

    //Clamp the camera position to the world bounds while centering the camera around the player                                             
    var camX = clamp(-player.x + canvas.width/2, yourWorld.minX, yourWorld.maxX - canvas.width);
    var camY = clamp(-player.y + canvas.height/2, yourWorld.minY, yourWorld.maxY - canvas.height);

    ctx.translate( camX, camY );    

    //Draw everything
}

夹子看起来像:

function clamp(value, min, max){
    if(value < min) return min;
    else if(value > max) return max;
    return value;
}
于 2014-07-13T04:34:42.043 回答
5

以下是如何使用画布作为另一个大于画布图像的视口

视口实际上只是显示给用户的较大图像的裁剪部分。

在这种情况下,视口将在画布上显示给用户(画布就是视口)。

首先,编写一个移动函数,在较大的图像周围平移视口。

此函数将视口的上/左角在指定方向上移动 5px:

function move(direction){
    switch (direction){
        case "left":
            left-=5;
            break;
        case "up":
            top-=5;
            break;
        case "right":
            left+=5;
            break;
        case "down":
            top+=5
            break;
    }
    draw(top,left);
}

move 函数调用 draw 函数。

在 draw() 中,该drawImage函数将裁剪较大图像的指定部分。

drawImage还将在画布上向用户显示“裁剪的背景”。

context.clearRect(0,0,game.width,game.height);
context.drawImage(background,cropLeft,cropTop,cropWidth,cropHeight,
                     0,0,viewWidth,viewHeight);

在这个例子中,

背景是完整的背景图像(通常不显示,而是裁剪的来源)

cropLeft 和cropTop 定义在背景图像上的哪里开始裁剪。

cropWidth 和cropHeight 定义从背景图像中裁剪出的矩形的大小。

0,0 表示从背景裁剪的子图像将在视口画布上的 0,0 处绘制。

viewWidth & viewHeight 是视口画布的宽度和高度

所以这里是一个使用数字的 drawImage 示例。

假设我们的视口(=我们的显示画布)是 150 像素宽和 100 像素高。

context.drawImage(background,75,50,150,100,0,0,150,100);

75 和 50 表示裁剪将从背景图像上的 x=75/y=50 位置开始。

150,100 表示要裁剪的矩形将是 150 宽和 100 高。

0,0,150,100 表示裁剪后的矩形图像将使用视口画布的完整尺寸显示。

这就是绘制视口的机制……只需添加键控制!

这是代码和小提琴:http: //jsfiddle.net/m1erickson/vXqyc/

<!doctype html>
<html>
<head>
<link rel="stylesheet" type="text/css" media="all" href="css/reset.css" /> <!-- reset css -->
<script type="text/javascript" src="http://code.jquery.com/jquery.min.js"></script>

<style>
    body{ background-color: ivory; }
    canvas{border:1px solid red;}
</style>

<script>
$(function(){

    var canvas=document.getElementById("canvas");
    var ctx=canvas.getContext("2d");
    var game=document.getElementById("game");
    var gameCtx=game.getContext("2d");

    var left=20;
    var top=20;

    var background=new Image();
    background.onload=function(){
        canvas.width=background.width/2;
        canvas.height=background.height/2;
        gameCtx.fillStyle="red";
        gameCtx.strokeStyle="blue";
        gameCtx.lineWidth=3;
        ctx.fillStyle="red";
        ctx.strokeStyle="blue";
        ctx.lineWidth=3;
        move(top,left);
    }
    background.src="https://dl.dropboxusercontent.com/u/139992952/stackoverflow/game.jpg";


    function move(direction){
        switch (direction){
            case "left":
                left-=5;
                break;
            case "up":
                top-=5;
                break;
            case "right":
                left+=5;
                break;
            case "down":
                top+=5
                break;
        }
        draw(top,left);
    }

    function draw(top,left){
        ctx.clearRect(0,0,canvas.width,canvas.height);
        ctx.drawImage(background,0,0,background.width,background.height,0,0,canvas.width,canvas.height);
        gameCtx.clearRect(0,0,game.width,game.height);
        gameCtx.drawImage(background,left,top,250,150,0,0,250,150);
        gameCtx.beginPath();
        gameCtx.arc(125,75,10,0,Math.PI*2,false);
        gameCtx.closePath();
        gameCtx.fill();
        gameCtx.stroke();
        ctx.beginPath();
        ctx.rect(left/2,top/2,125,75);
        ctx.stroke();
        ctx.beginPath();
        ctx.arc(left/2+125/2,top/2+75/2,5,0,Math.PI*2,false);
        ctx.stroke();
        ctx.fill();
    }

    $("#moveLeft").click(function(){move("left");}); 
    $("#moveRight").click(function(){move("right");}); 
    $("#moveUp").click(function(){move("up");}); 
    $("#moveDown").click(function(){move("down");}); 

}); // end $(function(){});
</script>

</head>

<body>
    <canvas id="game" width=250 height=150></canvas><br>
    <canvas id="canvas" width=500 height=300></canvas><br>
    <button id="moveLeft">Left</button>
    <button id="moveRight">Right</button>
    <button id="moveUp">Up</button>
    <button id="moveDown">Down</button>
</body>
</html>
于 2013-06-04T18:31:40.597 回答
2

这是一个简单的问题,将视口设置为目标的 x 和 y 坐标,正如 Colton 所说,在每一帧上。转换不是必需的,但可以根据需要使用。没有翻译的基本公式是:

function update() {

  // Assign the viewport to follow a target for this frame
  var viewportX = canvas.width / 2 - target.x;
  var viewportY = canvas.height / 2 - target.y;

  // Draw each entity, including the target, relative to the viewport
  ctx.fillRect(
    entity.x + viewportX, 
    entity.y + viewportY,
    entity.size,
    entity.size
  );
}

夹紧地图是可选的第二步,以将视口保持在世界范围内:

function update() {

  // Assign the viewport to follow a target for this frame
  var viewportX = canvas.width / 2 - target.x;
  var viewportY = canvas.height / 2 - target.y;

  // Keep viewport in map bounds
  viewportX = clamp(viewportX, canvas.width - map.width, 0);
  viewportY = clamp(viewportY, canvas.height - map.height, 0);

  // Draw each entity, including the target, relative to the viewport
  ctx.fillRect(
    entity.x + viewportX,
    entity.y + viewportY,
    entity.size,
    entity.size
  );
}

// Restrict n to a range between lo and hi
function clamp(n, lo, hi) {
  return n < lo ? lo : n > hi ? hi : n;
}

下面是几个例子。

没有视口平移,夹紧:

const clamp = (n, lo, hi) => n < lo ? lo : n > hi ? hi : n;

const Ship = function (x, y, angle, size, color) {
  this.x = x;
  this.y = y;
  this.vx = 0;
  this.vy = 0;
  this.ax = 0;
  this.ay = 0;
  this.rv = 0;
  this.angle = angle;
  this.accelerationAmount = 0.05;
  this.decelerationAmount = 0.02;
  this.friction = 0.9;
  this.rotationSpd = 0.01;
  this.size = size;
  this.radius = size;
  this.color = color;
};
Ship.prototype = {
  accelerate: function () {
    this.ax += this.accelerationAmount;
    this.ay += this.accelerationAmount;
  },
  decelerate: function () {
    this.ax -= this.decelerationAmount;
    this.ay -= this.decelerationAmount;
  },
  rotateLeft: function () {
    this.rv -= this.rotationSpd;
  },
  rotateRight: function () {
    this.rv += this.rotationSpd;
  },
  move: function () {
    this.angle += this.rv;
    this.vx += this.ax;
    this.vy += this.ay;
    this.x += this.vx * Math.cos(this.angle);
    this.y += this.vy * Math.sin(this.angle);
    this.ax *= this.friction;
    this.ay *= this.friction;
    this.vx *= this.friction;
    this.vy *= this.friction;
    this.rv *= this.friction;
  },
  
  draw: function (ctx, viewportX, viewportY) {
    ctx.save();
    ctx.translate(this.x + viewportX, this.y + viewportY);
    ctx.rotate(this.angle);
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(this.size / 1.2, 0);
    ctx.stroke();
    ctx.fillStyle = this.color;
    ctx.fillRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.strokeRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.restore();
  }
};

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
canvas.height = canvas.width = 180;
const map = {
  height: canvas.height * 5, 
  width: canvas.width * 5
};
const ship = new Ship(
  canvas.width / 2, 
  canvas.height / 2, 
  0,
  canvas.width / 10 | 0, 
  "#fff"
);

const keyCodesToActions = {
  38: () => ship.accelerate(),
  37: () => ship.rotateLeft(),
  39: () => ship.rotateRight(),
  40: () => ship.decelerate(),
};
const validKeyCodes = new Set(
  Object.keys(keyCodesToActions).map(e => +e)
);
const keysPressed = new Set();
document.addEventListener("keydown", e => {
  if (validKeyCodes.has(e.keyCode)) {
    e.preventDefault();
    keysPressed.add(e.keyCode);
  }
});
document.addEventListener("keyup", e => {
  if (validKeyCodes.has(e.keyCode)) {
    e.preventDefault();
    keysPressed.delete(e.keyCode);
  }
});

(function update() {
  requestAnimationFrame(update);

  keysPressed.forEach(k => {
    if (k in keyCodesToActions) {
      keyCodesToActions[k]();
    }
  });

  ship.move();

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.save();
  const viewportX = clamp(canvas.width / 2 - ship.x, canvas.width - map.width, 0);
  const viewportY = clamp(canvas.height / 2 - ship.y, canvas.height - map.height, 0);

  /* draw everything offset by viewportX/Y */
  const tileSize = canvas.width / 5;

  for (let x = 0; x < map.width; x += tileSize) {
    for (let y = 0; y < map.height; y += tileSize) {
      const xx = x + viewportX;
      const yy = y + viewportY;

      // simple culling
      if (xx > canvas.width || yy > canvas.height || 
          xx < -tileSize || yy < -tileSize) { 
        continue;
      }

      const light = (~~(x / tileSize + y / tileSize) & 1) * 5 + 70;
      ctx.fillStyle = `hsl(${360 - (x + y) / 10}, 50%, ${light}%)`;
      ctx.fillRect(xx, yy, tileSize + 1, tileSize + 1);
    }
  }
  
  ship.draw(ctx, viewportX, viewportY);
  ctx.restore();
})();
body { 
  margin: 0;
  font-family: monospace;
  display: flex; 
  flex-flow: row nowrap;
  align-items: center; 
}

html, body { 
  height: 100%; 
}

canvas { 
  background: #eee;
  border: 4px solid #222; 
}
div {
  transform: rotate(-90deg);
  background: #222;
  color: #fff;
  padding: 2px;
}
<div>arrow keys to move</div>

使用视口平移,松开:

const Ship = function (x, y, angle, size, color) {
  this.x = x;
  this.y = y;
  this.vx = 0;
  this.vy = 0;
  this.ax = 0;
  this.ay = 0;
  this.rv = 0;
  this.angle = angle;
  this.accelerationAmount = 0.05;
  this.decelerationAmount = 0.02;
  this.friction = 0.9;
  this.rotationSpd = 0.01;
  this.size = size;
  this.radius = size;
  this.color = color;
};
Ship.prototype = {
  accelerate: function () {
    this.ax += this.accelerationAmount;
    this.ay += this.accelerationAmount;
  },
  decelerate: function () {
    this.ax -= this.decelerationAmount;
    this.ay -= this.decelerationAmount;
  },
  rotateLeft: function () {
    this.rv -= this.rotationSpd;
  },
  rotateRight: function () {
    this.rv += this.rotationSpd;
  },
  move: function () {
    this.angle += this.rv;
    this.vx += this.ax;
    this.vy += this.ay;
    this.x += this.vx * Math.cos(this.angle);
    this.y += this.vy * Math.sin(this.angle);
    this.ax *= this.friction;
    this.ay *= this.friction;
    this.vx *= this.friction;
    this.vy *= this.friction;
    this.rv *= this.friction;
  },
  
  draw: function (ctx) {
    ctx.save();
    ctx.translate(this.x, this.y);
    ctx.rotate(this.angle);
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(this.size / 1.2, 0);
    ctx.stroke();
    ctx.fillStyle = this.color;
    ctx.fillRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.strokeRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.restore();
  }
};

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
canvas.height = canvas.width = 180;
const map = {
  height: canvas.height * 5, 
  width: canvas.width * 5
};
const ship = new Ship(
  canvas.width / 2, 
  canvas.height / 2, 
  0,
  canvas.width / 10 | 0, 
  "#fff"
);

const keyCodesToActions = {
  38: () => ship.accelerate(),
  37: () => ship.rotateLeft(),
  39: () => ship.rotateRight(),
  40: () => ship.decelerate(),
};
const validKeyCodes = new Set(
  Object.keys(keyCodesToActions).map(e => +e)
);
const keysPressed = new Set();
document.addEventListener("keydown", e => {
  if (validKeyCodes.has(e.keyCode)) {
    e.preventDefault();
    keysPressed.add(e.keyCode);
  }
});
document.addEventListener("keyup", e => {
  if (validKeyCodes.has(e.keyCode)) {
    e.preventDefault();
    keysPressed.delete(e.keyCode);
  }
});

(function update() {
  requestAnimationFrame(update);

  keysPressed.forEach(k => {
    if (k in keyCodesToActions) {
      keyCodesToActions[k]();
    }
  });

  ship.move();
  
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.save();
  ctx.translate(canvas.width / 2 - ship.x, canvas.height / 2 - ship.y);

  /* draw everything as normal */
  const tileSize = canvas.width / 5;

  for (let x = 0; x < map.width; x += tileSize) {
    for (let y = 0; y < map.height; y += tileSize) {

      // simple culling
      if (x > ship.x + canvas.width || y > ship.y + canvas.height || 
          x < ship.x - canvas.width || y < ship.y - canvas.height) { 
        continue;
      }

      const light = ((x / tileSize + y / tileSize) & 1) * 5 + 70;
      ctx.fillStyle = `hsl(${360 - (x + y) / 10}, 50%, ${light}%)`;
      ctx.fillRect(x, y, tileSize + 1, tileSize + 1);
    }
  }
  
  ship.draw(ctx);
  ctx.restore();
})();
body { 
  margin: 0;
  font-family: monospace;
  display: flex; 
  flex-flow: row nowrap;
  align-items: center; 
}

html, body { 
  height: 100%; 
}

canvas { 
  background: #eee;
  border: 4px solid #222; 
}
div {
  transform: rotate(-90deg);
  background: #222;
  color: #fff;
  padding: 2px;
}
<div>arrow keys to move</div>

如果您想让目标始终面向一个方向并旋转世界,请进行一些调整:

ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(target.angle); // adjust to match your world
ctx.translate(-target.x, -target.y);

/* draw everything as normal */

这是此变体的示例:

const Ship = function (x, y, angle, size, color) {
  this.x = x;
  this.y = y;
  this.vx = 0;
  this.vy = 0;
  this.ax = 0;
  this.ay = 0;
  this.rv = 0;
  this.angle = angle;
  this.accelerationAmount = 0.05;
  this.decelerationAmount = 0.02;
  this.friction = 0.9;
  this.rotationSpd = 0.01;
  this.size = size;
  this.radius = size;
  this.color = color;
};
Ship.prototype = {
  accelerate: function () {
    this.ax += this.accelerationAmount;
    this.ay += this.accelerationAmount;
  },
  decelerate: function () {
    this.ax -= this.decelerationAmount;
    this.ay -= this.decelerationAmount;
  },
  rotateLeft: function () {
    this.rv -= this.rotationSpd;
  },
  rotateRight: function () {
    this.rv += this.rotationSpd;
  },
  move: function () {
    this.angle += this.rv;
    this.vx += this.ax;
    this.vy += this.ay;
    this.x += this.vx * Math.cos(this.angle);
    this.y += this.vy * Math.sin(this.angle);
    this.ax *= this.friction;
    this.ay *= this.friction;
    this.vx *= this.friction;
    this.vy *= this.friction;
    this.rv *= this.friction;
  },
  
  draw: function (ctx) {
    ctx.save();
    ctx.translate(this.x, this.y);
    ctx.rotate(this.angle);
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(this.size / 1.2, 0);
    ctx.stroke();
    ctx.fillStyle = this.color;
    ctx.fillRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.strokeRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.restore();
  }
};

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
canvas.height = canvas.width = 180;
const map = {
  height: canvas.height * 5, 
  width: canvas.width * 5
};
const ship = new Ship(
  canvas.width / 2, 
  canvas.height / 2, 
  0,
  canvas.width / 10 | 0, 
  "#fff"
);

const keyCodesToActions = {
  38: () => ship.accelerate(),
  37: () => ship.rotateLeft(),
  39: () => ship.rotateRight(),
  40: () => ship.decelerate(),
};
const keysPressed = new Set();
document.addEventListener("keydown", e => {
  e.preventDefault();
  keysPressed.add(e.keyCode);
});
document.addEventListener("keyup", e => {
  e.preventDefault();
  keysPressed.delete(e.keyCode);
});

(function update() {
  requestAnimationFrame(update);

  keysPressed.forEach(k => {
    if (k in keyCodesToActions) {
      keyCodesToActions[k]();
    }
  });

  ship.move();

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.save();
  ctx.translate(canvas.width / 2, canvas.height / 1.4);
  //                                              ^^^ optionally offset y a bit
  //                                                  so the player can see better

  ctx.rotate(-90 * Math.PI / 180 - ship.angle);
  ctx.translate(-ship.x, -ship.y);
  
  /* draw everything as normal */
  const tileSize = ~~(canvas.width / 5);

  for (let x = 0; x < map.width; x += tileSize) {
    for (let y = 0; y < map.height; y += tileSize) {

      // simple culling
      if (x > ship.x + canvas.width || y > ship.y + canvas.height || 
          x < ship.x - canvas.width || y < ship.y - canvas.height) { 
        continue;
      }

      const light = ((x / tileSize + y / tileSize) & 1) * 5 + 70;
      ctx.fillStyle = `hsl(${360 - (x + y) / 10}, 50%, ${light}%)`;
      ctx.fillRect(x, y, tileSize + 1, tileSize + 1);
    }
  }

  ship.draw(ctx);
  ctx.restore();
})();
body { 
  margin: 0;
  font-family: monospace;
  display: flex; 
  flex-flow: row nowrap;
  align-items: center; 
}

html, body { 
  height: 100%; 
}

canvas { 
  background: #eee;
  border: 4px solid #222; 
}
div {
  transform: rotate(-90deg);
  background: #222;
  color: #fff;
  padding: 2px;
}
<div>arrow keys to move</div>

有关使用物理引擎的玩家视角视口的示例,请参阅此相关答案。

于 2018-01-13T03:28:00.017 回答
1

你现在的做法对我来说似乎是正确的。不过,我会将“20”边界更改为变量,因此如果需要,您可以轻松更改关卡或整个游戏的边界。

您可以将此逻辑抽象为特定的“视口”方法,该方法将简单地处理确定“相机”需要在地图上的位置所需的计算,然后确保角色的 X 和 Y 坐标与你的相机。

您还可以翻转该方法并根据字符位置(例如:)确定相机的位置,(position.x - (desired_camera_size.width / 2))然后从那里画出相机。

当您确定了相机位置后,您可以开始担心将房间本身绘制为画布的第一层。

于 2013-06-04T14:00:06.750 回答
1

@gustavo-carvalho 的解决方案非常出色,但它涉及大量计算和认知开销。@Colton 的方法是朝着正确方向迈出的一步;太糟糕了,他的回答不够详细。我接受了他的想法并用它来创建这个 CodePen。它完全实现了@user2337969 要求使用的context.translate. 美妙之处在于,这不需要偏移任何地图或玩家坐标,因此绘制它们就像直接使用它们一样简单xy更直接。

将 2D 相机想象成一个在更大地图内平移的矩形。它的左上角(x, y)在地图中的坐标处,它的大小是画布的大小,即canvas.widthcanvas.height。这意味着x范围可以从0tomap.width - canvas.widthyfrom 0to map.height - canvas.height(包括)。这些是我们min提供max给@Coltonclamp方法的内容。

然而,为了使它工作,我不得不打开标志xy因为有了context.translate,正值将画布向右移动(产生一种错觉,好像相机向左平移)和负值 - 向左移动(好像相机平移向右)。

于 2019-06-27T06:28:48.227 回答
1

将以下代码另存为 .HTM (.html) 文件并在浏览器中打开。

结果应该完全匹配这个屏幕截图

这是一些将不同大小的视口相互映射的示例代码。尽管此实现使用像素,但您可以扩展此逻辑以渲染图块。我实际上将我的 tilemaps 存储为 .PNG 文件。根据像素的颜色,它可以表示不同的图块类型。此处的代码旨在从视口 1、2 或 3 进行采样,并将结果粘贴到视口 0。

Youtube 视频播放列表的屏幕截图和代码直接在下面:REC_MAP

REC_MAP.HTM 截图

编辑:REC_MAP.HTM 代码移至 PASTEBIN: https ://pastebin.com/9hWs8Bag

第 2 部分:BUF_VEW.HTM(从屏幕外缓冲区采样) 我们将重构上一个演示中的代码,以便我们的源视口对屏幕外的位图进行采样。最终,我们会将位图上的每个像素颜色解释为唯一的平铺值。我们在这段代码中并没有走得太远,这只是为了让我们的一个视口离开屏幕而进行的重构。我在这里记录了整个过程。没有编辑。包括我在内的整个过程花费了太长时间来想出变量名。

Youtube 视频播放列表的屏幕截图和代码直接在下面:BUF_VEW

BUF_VEW.HTM

和以前一样,您可以获取此源代码,将其保存为 .HTM (.html) 文件,然后在浏览器中运行它。

编辑:BUF_VEW.HTM 代码移至 PASTEBIN: https ://pastebin.com/zedhD60u

第 3 部分:UIN_ADA.HTM(用户输入适配器和捕捉相机) 我们现在要从第 2 部分编辑之前的 BUF_VEW.HTM 文件并添加 2 个新功能。

1:用户输入处理

2:可以放大缩小和移动的相机。

该相机将以其自己的视口选择区域宽度和高度为增量移动,这意味着运动将非常“快速”。这台相机是为关卡编辑而设计的,而不是真正的游戏内玩。我们首先关注关卡编辑器相机。长期的最终目标是使编辑器代码和游戏内代码成为相同的代码。唯一的区别应该是在游戏模式下,相机的行为会有所不同,并且瓷砖地图编辑将被禁用。

截屏和代码的 Youtube 视频播放列表直接在下面:UIN_ADA

UIN_ADA.PNG

复制下面的代码,另存为:“UIN_ADA.HTM”并在浏览器中运行。

控件:箭头和“+”“-”用于相机放大、缩小。

编辑:UIN_ADA.HTM 移至 PASTEBIN: https ://pastebin.com/ntmWihra


第 4 部分:DAS_BOR.HTM (DAShed_BOaRders) 我们将进行一些计算以在每个图块周围绘制一个 1 像素的薄边框。结果不会很花哨,但它会帮助我们验证我们是否能够获取每个图块的本地坐标并对它们做一些有用的事情。这些图块本地坐标对于在以后的部分中将位图图像映射到图块上是必需的。

Youtube_Playlist:DAS_BOR.HTM 源代码:DAS_BOR.HTM Html 页面预览:DAS_BOR.HTM

第 5 部分:缩放 + 平移 WebGL 画布片段着色器代码:这是缩放和平移用 GLSL 编写的着色器所需的数学运算。我们不采用屏幕外数据的子样本,而是采用 gl_FragCoord 值的子样本。这里的数学允许插入屏幕视口和可以缩放和平移着色器的相机。如果您已经完成了“Lewis Lepton”的着色器教程并且您想对其进行缩放和平移,您可以通过这个逻辑过滤他的输入坐标,并且应该这样做。

JavaScript 代码

代码快速视频解释

解释视口布局的 Ascii 图


第 6 部分:ICOG.JS:DAS_BOR.HTM 的 WebGL2 端口 要运行它,您需要将脚本包含在一个空的 .HTM 文件中。它复制了在 DAS_BOR.HTM 中发现的相同行为,但所有渲染都是使用 GLSL 着色器代码完成的。代码中还有完整游戏框架的构成。

用法:

1:按“~”告诉主编辑读取输入。

2:按“2”进入编辑器#2,即平铺编辑器。

3:WASD 移动 512x512 内存子部分。

4:箭头键将相机移动恰好 1 个相机。

5:“+”和“-”键改变相机的“变焦级别”。

尽管此代码只是将每个图块值呈现为渐变方块,但它演示了获取正确图块值和当前正在绘制的图块的内部坐标的能力。有了着色器代码中瓦片的本地坐标,您就可以将图像映射到这些瓦片上的基础数学。

完整的 JavaScript Webgl2 代码

Youtube 播放列表记录了 ICOG.JS 的创建

ICOG.JS 编辑器#2 的屏幕截图

//|StackOverflow Says:
//|Links to pastebin.com must be accompanied by code. Please |//
//|indent all code by 4 spaces using the code toolbar button |//
//|or the CTRL+K keyboard shortcut. For more editing help,   |//
//|click the [?] toolbar icon.                               |//
//|                                                          |//
//|StackOverflow Also Says (when I include the code here)    |//
//|You are over you 30,000 character limit for posts.        |//
function(){ console.log("[FixingStackOverflowComplaint]"); }
于 2020-05-07T02:55:07.217 回答