3DTank


上下左右キーで3D空間を走り回り、敵戦車を撃墜するゲームです。三角関数・ベクトル・行列を使って座標計算を行い、Canvas上に描画しています。Advanced(上級)クラスでこれらの内容をカバーする予定です。以下の書籍で実装の詳細について解説しています。
今となってはレトロ感満載ですが、自分が学生の頃はこの程度の3Dグラフィックスでも感銘を受けていました。

ソースコード


<!DOCTYPE html>
<html>
<!-- Copyright 2016 Kenichiro Tanaka -->
<head>
    <meta charset="utf-8" />
    <title>3D Tank</title>
    <style>
        #field {touch-action:none;}
    </style>
    <script>
        "use strict";
        var ctx, map, width = 600, height = 600;
        var keymap = [], shapes = [], shots = [], tanks = [];
        var cameraTheta = 0, cameraX = 0, cameraZ = 0;
        var soundEffect;

        function rand(val) { return Math.floor(Math.random() * val - val / 2) }

        function Vec3(x, y, z) {
            this.x = x;
            this.y = y;
            this.z = z;
        }

        function Model(data) {
            this.data = data;
            this.work = [];
            for (var i = 0 ; i < data.length ; i++) {
                var r = [], v = data[i];
                for (var j = 0 ; j < v.length ; j++) {
                    r.push(new Vec3(v[j].x, v[j].y, v[j].z));
                }
                this.work.push(r);
            }
        }

        Model.prototype.init = function () {
            for (var i = 0 ; i < this.work.length ; i++) {
                var w = this.work[i];
                var d = this.data[i];
                for (var j = 0 ; j < w.length ; j++) {
                    w[j].x = d[j].x;
                    w[j].y = d[j].y;
                    w[j].z = d[j].z;
                }
            }
        }

        Model.prototype.iter = function (f) {
            for (var i = 0 ; i < this.work.length ; i++) {
                var v = this.work[i];
                for (var j = 0 ; j < v.length ; j++) {
                    f(v[j]);
                }
            }
        }

        Model.prototype.apply = function (m) {
            this.iter(function (v) {
                var x = m[0] * v.x + m[1] * v.y + m[2] * v.z;
                var y = m[3] * v.x + m[4] * v.y + m[5] * v.z;
                var z = m[6] * v.x + m[7] * v.y + m[8] * v.z;
                v.x = x;
                v.y = y;
                v.z = z;
            })
        }

        Model.prototype.translate = function (x,y,z) {
            this.iter(function (v) {
                v.x = v.x + x;
                v.y = v.y + y;
                v.z = v.z + z;
            })
        }

        function createMatrixY(radian) {
            var c = Math.cos(radian);
            var s = Math.sin(radian);
            return [c, 0, s, 0, 1, 0, -s, 0, c];
        }

        function Shot(x, z, dir) {
            this.x = x;
            this.z = z;
            this.dx = -Math.sin(dir) * 5;
            this.dz = Math.cos(dir) * 5;
            this.count = 0;
            this.model = new Model([[
                new Vec3(x, -5, z),
                new Vec3(x + this.dx, -5, z + this.dz)]
            ]);
            this.update = function () {
                this.model.init();
                this.count++;
                this.model.translate(this.dx * this.count, 0, this.dz * this.count);
            }
            this.isValid = function () {
                return (this.count < 30);
            }
            this.getX = function () { return this.x + this.dx * this.count; }
            this.getZ = function () { return this.z + this.dz * this.count; }
        }

        function Tile() {
            var polygon = [];
            for (var x = -200 ; x < 200 ; x += 10) {
                for (var z = -200 ; z < 200 ; z += 10) {
                    polygon.push([
                        new Vec3(x, -5, z),
                        new Vec3(x + 10, -5, z),
                        new Vec3(x + 10, -5, z + 10),
                        new Vec3(x, -5, z + 10),
                    ]);
                }
            }
            this.model = new Model(polygon);
            this.update = function () { this.model.init(); }
            this.getColor = function () { return "#FF0000"; }
        }

        function Tank() {
            var v = [
                new Vec3(-10, -5, -5),
                new Vec3(-10, -5, +5),
                new Vec3(10, -5, 0),
                new Vec3(-8, 2, 0)
            ];
            var polygon = [
                [v[0], v[1], v[2]],
                [v[0], v[1], v[3]],
                [v[1], v[2], v[3]],
                [v[2], v[0], v[3]]
            ];
            this.model = new Model(polygon);
            this.valid = true;

            this.setDestination = function (x, z, t) {
                this.x = x || rand(400);
                this.z = z || rand(400);
                this.t = t || 0
                this.nx = rand(400);
                this.nz = rand(400);
                this.nt = -Math.atan2(this.nz - this.z, this.nx - this.x);
                this.count = 0;
                this.rotating = true;
                this.matrix = createMatrixY(this.nt);
            }

            this.getX = function () {
                return this.x + (this.rotating ?
                    0 : (this.nx - this.x) * this.count / 100);
            }
            this.getZ = function () {
                return this.z + (this.rotating ?
                    0 : (this.nz - this.z) * this.count / 100);
            }

            this.getColor = function () { return "#00FF00"; }
            this.isValid = function () { return this.valid; }
            this.update = function () {
                this.model.init();
                this.count++;

                if (this.rotating) {
                    var dir = (this.nt - this.t) * this.count / 20 + this.t;
                    this.matrix = createMatrixY(dir);
                    if (this.count > 20) {
                        this.rotating = false;
                        this.count = 0;
                    }
                }

                this.model.apply(this.matrix);
                this.model.translate(this.getX(), 0, this.getZ())

                if (this.count > 100) {
                    this.setDestination(this.nx, this.nz, this.nt);
                }

                for (var i = 0 ; i < shots.length ; i++) {
                    var dx = Math.abs(this.getX() - shots[i].getX());
                    var dz = Math.abs(-this.getZ() + shots[i].getZ());
                    if (dx < 10 && dz < 10) {
                        shapes.push(new Bang(this));
                        this.valid = false;
                        addTank();
                        soundEffect.play();
                    }
                }
            }
            
            this.setDestination();
        }


        function Bang(tank) {
            var w0 = tank.model.work[0][0];
            var w1 = tank.model.work[0][1];
            var w2 = tank.model.work[0][2];
            var w3 = tank.model.work[1][2];
            var polygon = [
                [new Vec3(w0.x, w0.y, w0.z), new Vec3(w1.x, w1.y, w1.z)],
                [new Vec3(w1.x, w1.y, w1.z), new Vec3(w2.x, w2.y, w2.z)],
                [new Vec3(w2.x, w2.y, w2.z), new Vec3(w0.x, w0.y, w0.z)],
                [new Vec3(w0.x, w0.y, w0.z), new Vec3(w3.x, w3.y, w3.z)],
                [new Vec3(w1.x, w1.y, w1.z), new Vec3(w3.x, w3.y, w3.z)],
                [new Vec3(w2.x, w2.y, w2.z), new Vec3(w3.x, w3.y, w3.z)],
            ]
            this.model = new Model(polygon);
            this.count = 0;
            this.colors = [];
            for (var i = 15 ; i >= 0 ; i--) {
                this.colors.push("#0" + i.toString(16) +"0");
            }

            this.r = []
            for (var i = 0 ; i < 12 ; i++) {
                this.r.push(new Vec3(rand(20), rand(20), rand(20)));
            }

            this.update = function () {
                this.model.init();
                this.count++;
                var index = 0;
                for (var i = 0 ; i < this.model.work.length ; i++) {
                    var v = this.model.work[i];
                    for (var j = 0 ; j < v.length ; j++) {
                        v[j].x += (this.r[index].x) * this.count / 16;
                        v[j].y += (this.r[index].y) * this.count / 16;
                        v[j].z += (this.r[index].z) * this.count / 16;
                        index++;
                    }
                }
            }
            this.isValid = function () { return this.count < 16; }
            this.getColor = function () { return this.colors[this.count]; }
        }

        var proto = {
            setCamera: function (cameraX, cameraZ, cameraMatrix) {
                this.model.translate(-cameraX, 0, -cameraZ);
                this.model.apply(cameraMatrix);
            },
            isValid: function () { return true; },
            getColor: function () { return "#FFFFFF"; }
        }

        Tile.prototype = proto;
        Shot.prototype = proto;
        Tank.prototype = proto;
        Bang.prototype = proto;

        function init() {
            ctx = document.getElementById("field").getContext("2d");
            map = document.getElementById("map").getContext("2d");
            shapes.push(new Tile());
            for (var i = 0 ; i < 4 ; i++) {
                addTank();
            }

            soundEffect = new Audio("glass.mp3");
            onkeydown = function (e) { keymap[e.keyCode] = true; }
            onkeyup = function (e) { keymap[e.keyCode] = false; }
            setInterval(tick, 100)
            paint();
        }

        function tick() {
            if (keymap[37]) { cameraTheta += 0.1; } // left
            if (keymap[39]) { cameraTheta -= 0.1; } // right
            if (keymap[38]) {    // up
                cameraX -= Math.sin(cameraTheta) * 3;
                cameraZ += Math.cos(cameraTheta) * 3;
            }
            if (keymap[40]) {    // down
                cameraX += Math.sin(cameraTheta) * 3;
                cameraZ -= Math.cos(cameraTheta) * 3;
            }
            if (keymap[32]) {
                keymap[32] = false;
                var shot = new Shot(cameraX, cameraZ, cameraTheta);
                shots.push(shot);
                shapes.push(shot);
            }

            var cameraMatrix = createMatrixY(cameraTheta);
            for (var i = 0 ; i < shapes.length ; i++) {
                shapes[i].update();
                shapes[i].setCamera(cameraX, cameraZ, cameraMatrix);
            }

            shapes = shapes.filter(function (s) { return s.isValid() });
            shots = shots.filter(function (s) { return s.isValid() });
            tanks = tanks.filter(function (t) { return t.isValid() })

            paint();
        }

        function addTank() {
            var tank = new Tank();
            tanks.push(tank);
            shapes.push(tank);
        }

        function paint() {
            ctx.fillStyle = "black";
            ctx.fillRect(0, 0, width, height);
            ctx.strokeStyle = "white";

            for (var i = 0 ; i < shapes.length ; i++) {
                var polygon = shapes[i].model.work;
                ctx.strokeStyle = shapes[i].getColor();
                for (var j = 0 ; j < polygon.length ; j++) {
                    ctx.beginPath();

                    var vertices = polygon[j];
                    for (var k = 0 ; k < vertices.length ; k++) {
                        var p = vertices[k];
                        if (p.z < 0) continue;
                        var x = p.x / p.z * 1000 + 300;
                        var y = -p.y / p.z * 1000 + 300;

                        if (k == 0) {
                            ctx.moveTo(x, y)
                        } else {
                            ctx.lineTo(x, y)
                        }
                    }
                    ctx.stroke();
                }
            }

            map.fillStyle = "black";
            map.fillRect(0, 0, width, height);
            map.fillStyle = "blue";
            map.save();
            map.translate(150, 150);
            map.scale(3 / 4, - 3 / 4);
            map.fillRect(cameraX, cameraZ, 5, 5);
            map.globalAlpha = 0.6;
            map.beginPath();
            map.moveTo(cameraX, cameraZ);
            map.arc(cameraX, cameraZ, 300,
                cameraTheta + Math.PI / 2 - 0.4,
                cameraTheta + Math.PI / 2 + 0.4);
            map.fill();
            map.fillStyle = "lightgreen";
            for (var i = 0 ; i < tanks.length ; i++) {
                map.fillRect(tanks[i].getX(), tanks[i].getZ(), 7, 7);
            }
            map.fillStyle = "white";
            for (var i = 0 ; i < shots.length ; i++) {
                map.fillRect(shots[i].getX(), shots[i].getZ(), 5, 5);
            }
            map.restore();
        }

        ontouchstart = mymousedown;
        onmousedown = mymousedown;
        onmouseup = mymouseup;
        ontouchend = mymouseup;
        oncontextmenu = function(e) {e.preventDefault(); }
        function mymousedown(e){
            var mouseX = !isNaN(e.offsetX) ? e.offsetX : e.touches[0].clientX;
            var mouseY = !isNaN(e.offsetY) ? e.offsetY : e.touches[0].clientY;
            mouseX -= ctx.canvas.width / 2;
            mouseY -= ctx.canvas.height / 2;
            if (Math.abs(mouseX) > Math.abs(mouseY)){
                if (mouseX < 0){
                    keymap[37] = true;
                }else{
                    keymap[39] = true;
                }
            }else{
                if (mouseY < 0){
                    keymap[38] = true;
                }else{
                    keymap[40] = true;                    
                }
            }
        }
        function mymouseup(e){
            keymap = [];
        }
    </script>
</head>
<body onload="init()">
    <canvas id="field" width="600" height="600" style="width:600px; height:600px"></canvas>
    <canvas id="map" width="300" height="300" style="width:300px; height:300px"></canvas>
</body>
</html>

""" 3D Tank - Copyright 2016 Kenichiro Tanaka """
import sys
from random import randint
from math import sin, cos, atan2, pi
import pygame
from pygame.locals import Rect, QUIT, \
    KEYDOWN, K_LEFT, K_RIGHT, K_UP, K_DOWN, K_SPACE

pygame.init()
pygame.key.set_repeat(5, 5)
SURFACE = pygame.display.set_mode((1000, 600))
RADAR = pygame.Surface((400, 400))
FPSCLOCK = pygame.time.Clock()
SHAPES = []
SHOTS = []
TANKS = []
CAMERA_THETA = 0
CAMERA = [0, 0]

def create_rotate_matrix(theta):
    """ create rotate matrix around Y-axis """
    cos_v = cos(theta)
    sin_v = sin(theta)
    return (cos_v, 0, sin_v, 0, 1, 0, -sin_v, 0, cos_v)

class Shape:
    """ Super class of all shape objects (Tank, Shot, Bang) """
    def __init__(self):
        self.model = None

    def set_camera(self, camera_x, camera_z, camera_matrix):
        """ set camera and updates the each vertex """
        self.model.translate(-camera_x, 0, -camera_z)
        self.model.apply(camera_matrix)

    def is_valid(self):
        """ return this shape is still valid or not """
        return True

    def get_color(self):
        """ get the color of this shape """
        return (255, 255, 255)

class Model:
    """ Class to hold original and current pos of vertices """
    def __init__(self, polygons):
        self.polygons = polygons
        self.work = []
        for vertices in polygons:
            tmp = []
            for vertex in vertices:
                tmp.append([vertex[0], vertex[1], vertex[2]])
            self.work.append(tmp)

    def reset(self):
        """ reset all coordinates of current positions """
        for ipos, vertices in enumerate(self.polygons):
            for jpos in range(len(vertices)):
                self.work[ipos][jpos][0] \
                    = self.polygons[ipos][jpos][0]
                self.work[ipos][jpos][1] \
                    = self.polygons[ipos][jpos][1]
                self.work[ipos][jpos][2] \
                    = self.polygons[ipos][jpos][2]

    def apply(self, matrix):
        """ apply a matrix and update coordinates """
        for vertices in self.work:
            for vertex in vertices:
                xpos = matrix[0] * vertex[0] + \
                    matrix[1] * vertex[1] + matrix[2] * vertex[2]
                ypos = matrix[3] * vertex[0] + \
                    matrix[4] * vertex[1] + matrix[5] * vertex[2]
                zpos = matrix[6] * vertex[0] + \
                    matrix[7] * vertex[1] + matrix[8] * vertex[2]
                vertex[0], vertex[1], vertex[2] = xpos, ypos, zpos

    def translate(self, move_x, move_y, move_z):
        """ move all coordinates """
        for vertices in self.work:
            for vertex in vertices:
                vertex[0] += move_x
                vertex[1] += move_y
                vertex[2] += move_z

class Shot(Shape):
    """ Bullet object shot by you """
    def __init__(self, xpos, zpos, theta):
        super().__init__()
        self.xpos = xpos
        self.zpos = zpos
        self.step = (-sin(theta) * 5, cos(theta) * 5)
        self.count = 0
        polygons = []
        polygons.append(((xpos, -5, zpos),
                         (xpos+self.step[0], -5, zpos+self.step[1])))
        self.model = Model(polygons)

    def update(self):
        """ move the model """
        self.model.reset()
        self.count += 1
        self.model.translate(self.step[0] * self.count, 0,
                             self.step[1] * self.count)

    def is_valid(self):
        """ return if this shot is still valid or not """
        return self.count < 30

    def get_x(self):
        """ return the x position of this bullet """
        return self.xpos + self.step[0] * self.count

    def get_z(self):
        """ return the z position of this bullet """
        return self.zpos + self.step[1] * self.count

class Tile(Shape):
    """ Tile object on the floor """
    def __init__(self):
        super().__init__()

        polygons = []
        for xpos in range(-200, 200, 10):
            for zpos in range(-200, 200, 10):
                polygons.append((
                    (xpos, -5, zpos),
                    (xpos + 10, -5, zpos),
                    (xpos + 10, -5, zpos + 10),
                    (xpos, -5, zpos + 10)))
        self.model = Model(polygons)

    def update(self):
        """ reset the coordinate of each vertex """
        self.model.reset()

    def get_color(self):
        """ return the color of the floor """
        return (255, 0, 0)

class Tank(Shape):
    """ Tank object """
    vert = [(-10, -5, -5), (-10, -5, +5), (10, -5, 0), (-8, 2, 0)]
    polygons = ((vert[0], vert[1], vert[2]),
                (vert[0], vert[1], vert[3]),
                (vert[1], vert[2], vert[3]),
                (vert[2], vert[0], vert[3]))

    def __init__(self):
        super().__init__()

        self.model = Model(self.polygons)
        self.valid = True
        self.xpos = 0
        self.zpos = 0
        self.theta = 0
        self.next_x = 0
        self.next_z = 0
        self.next_t = 0
        self.count = 0
        self.rotating = False
        self.matrix = None
        self.set_destination(randint(0, 400) - 200,
                             randint(0, 400) - 200, 0)

    def set_destination(self, xpos, zpos, theta):
        """ set the next destination to move to """
        self.xpos = xpos
        self.zpos = zpos
        self.theta = theta
        self.next_x = randint(0, 400) - 200
        self.next_z = randint(0, 400) - 200
        self.next_t = -atan2(self.next_z - self.zpos,
                             self.next_x - self.xpos)
        self.count = 0
        self.rotating = True
        self.matrix = create_rotate_matrix(self.next_t)

    def get_x(self):
        """ return the current x position """
        return self.xpos + (0 if self.rotating \
            else (self.next_x - self.xpos) * self.count / 100)

    def get_z(self):
        """ return the current z position """
        return self.zpos + (0 if self.rotating \
            else (self.next_z - self.zpos) * self.count / 100)

    def get_color(self):
        """ return the color of the tank """
        return (00, 255, 00)

    def is_valid(self):
        """ return if this tank is still alive """
        return self.valid

    def update(self):
        """ move the tank and check if this tank is shoot """
        self.model.reset()
        self.count += 1
        if self.rotating:
            direction = (self.next_t - self.theta) * self.count / 20\
                + self.theta
            self.matrix = create_rotate_matrix(direction)
            if self.count > 20:
                self.rotating = False
                self.count = 0

        self.model.apply(self.matrix)
        self.model.translate(self.get_x(), 0, self.get_z())

        if self.count > 100:
            self.set_destination(self.next_x,
                                 self.next_z, self.next_t)

        for shot in SHOTS:
            diffx = abs(self.get_x() - shot.get_x())
            diffz = abs(self.get_z() - shot.get_z())
            if diffx < 10 and diffz < 10:
                SHAPES.append(Bang(self))
                self.valid = False
                add_tank()

class Bang(Shape):
    """ Explosion object """
    def __init__(self, tank):
        super().__init__()
        pos0 = tank.model.work[0][0]
        pos1 = tank.model.work[0][1]
        pos2 = tank.model.work[0][2]
        pos3 = tank.model.work[1][2]
        polygons = (
            ((pos0[0], pos0[1], pos0[2]),
             (pos1[0], pos1[1], pos1[2])),
            ((pos1[0], pos1[1], pos1[2]),
             (pos2[0], pos2[1], pos2[2])),
            ((pos2[0], pos2[1], pos2[2]),
             (pos0[0], pos0[1], pos0[2])),
            ((pos0[0], pos0[1], pos0[2]),
             (pos3[0], pos3[1], pos3[2])),
            ((pos1[0], pos1[1], pos1[2]),
             (pos3[0], pos3[1], pos3[2])),
            ((pos2[0], pos2[1], pos2[2]),
             (pos3[0], pos3[1], pos3[2])))

        self.model = Model(polygons)
        self.count = 0
        self.colors = []
        for col in range(255, 0, -15):
            self.colors.append((0, col, 0))
        self.bangs = []
        for _ in range(12):
            self.bangs.append((randint(0, 20) - 10,
                               randint(0, 20) - 10, randint(0, 20) - 10))

    def update(self):
        """ update positions of explosion """
        self.model.reset()
        self.count += 1
        for num in range(12):
            vertex = self.model.work[num//2][num%2]
            vertex[0] += self.bangs[num][0] * self.count / 16
            vertex[1] += self.bangs[num][1] * self.count / 16
            vertex[2] += self.bangs[num][2] * self.count / 16

    def is_valid(self):
        """ return if still in the middle of the explosion """
        return self.count < 16

    def get_color(self):
        """ return the color of this explosion """
        return self.colors[self.count]

def tick():
    """ called periodically from the main loop """
    global CAMERA_THETA, SHAPES, SHOTS, TANKS
    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            sys.exit()
        elif event.type == KEYDOWN:
            if event.key == K_LEFT:
                CAMERA_THETA += 0.1
            elif event.key == K_RIGHT:
                CAMERA_THETA -= 0.1
            elif event.key == K_UP:
                CAMERA[0] -= sin(CAMERA_THETA) * 3
                CAMERA[1] += cos(CAMERA_THETA) * 3
            elif event.key == K_DOWN:
                CAMERA[0] += sin(CAMERA_THETA) * 3
                CAMERA[1] -= cos(CAMERA_THETA) * 3
            elif event.key == K_SPACE:
                shot = Shot(CAMERA[0], CAMERA[1], CAMERA_THETA)
                SHOTS.append(shot)
                SHAPES.append(shot)

    camera_matrix = create_rotate_matrix(CAMERA_THETA)
    for shape in SHAPES:
        shape.update()
        shape.set_camera(CAMERA[0], CAMERA[1], camera_matrix)

    SHAPES = [x for x in SHAPES if x.is_valid()]
    SHOTS = [x for x in SHOTS if x.is_valid()]
    TANKS = [x for x in TANKS if x.is_valid()]

def add_tank():
    """ add a tank at random position """
    tank = Tank()
    TANKS.append(tank)
    SHAPES.append(tank)

def paint():
    "update the screen"
    SURFACE.fill((0, 0, 0))

    # Paint polygons
    for shape in SHAPES:
        polygons = shape.model.work
        for vertices in polygons:
            poly = []
            for vertex in vertices:
                zpos = vertex[2]
                if zpos <= 1:
                    continue
                poly.append((vertex[0] / zpos * 1000 + 300,
                             -vertex[1] / zpos * 1000 + 300))

            if len(poly) > 1:
                pygame.draw.lines(SURFACE, 
                                  shape.get_color(), True, poly)

    # Paint radar map
    xpos, zpos, theta = CAMERA[0], CAMERA[1], CAMERA_THETA
    RADAR.set_alpha(128)
    RADAR.fill((128, 128, 128))
    pygame.draw.arc(RADAR, (0, 0, 225),
                    Rect(xpos+100, -zpos+100, 200, 200),
                    theta-0.6+pi/2, theta+0.6+pi/2, 100)
    pygame.draw.rect(RADAR, (225, 0, 0),
                     Rect(xpos+200, -zpos+200, 5, 5))
    for tank in TANKS:
        pygame.draw.rect(RADAR, (0, 255, 0),
                         Rect(tank.get_x()+200, -tank.get_z()+200, 5, 5))
    for shot in SHOTS:
        pygame.draw.rect(RADAR, (225, 225, 225),
                         Rect(shot.get_x()+200, -shot.get_z()+200, 5, 5))
    scaled_radar = pygame.transform.scale(RADAR, (300, 300))
    SURFACE.blit(scaled_radar, (650, 50))
    pygame.display.update()

def main():
    """ main routine """
    SHAPES.append(Tile())
    for _ in range(6):
        add_tank()

    while True:
        tick()
        paint()
        FPSCLOCK.tick(15)

if __name__ == '__main__':
    main()