3D Block


ブロック崩しの3D版です。壁は描画するつもりでしたが試してみたらちょうどよい難易度に思われたので意図的に作るのをやめました。三角関数・ベクトル・行列を使って座標計算を行い、Canvas上に描画しています。Advanced(上級)クラスでこれらの内容をカバーする予定です。以下の書籍で実装の詳細について解説しています。
ソースコードはそれほど長くないので数学に自信のある人は解読してみてください。

ソースコード


<!DOCTYPE html>
<html>
<!-- Copyright 2016 Kenichiro Tanaka -->
<head>
    <meta charset="utf-8" />
    <title>3D Block</title>
    <style>
        #field {touch-action:none;}
    </style>    
    <script>
        "use strict";
        var ctx, timer, keymap = [], blocks = [], paddle, ball;
        var speed = 5, message = "";
        var theta = 260 + Math.floor(Math.random() * 20);

        function deg2rad(val) { return Math.PI * val / 180; }

        function Cube(x, y, z, w, h, d, color) {
            this.x = x;
            this.y = y;
            this.w = w;
            this.h = h;
            this.pos = []
            this.color = color;

            this.vertices = [
                { x: x - w, y: y - h, z: z + d },
                { x: x - w, y: y + h, z: z + d },
                { x: x + w, y: y + h, z: z + d },
                { x: x + w, y: y - h, z: z + d },
                { x: x - w, y: y - h, z: z - d },
                { x: x - w, y: y + h, z: z - d },
                { x: x + w, y: y + h, z: z - d },
                { x: x + w, y: y - h, z: z - d },
            ];
            
            this.polygons = [
                [2, 1, 5, 6],
                [0, 1, 2, 3],
                [4, 5, 1, 0],
                [2, 6, 7, 3],
                [7, 6, 5, 4],
                [0, 3, 7, 4]
            ];

            this.rotateXY = function (radX, radY) {
                for (var i = 0 ; i < this.vertices.length ; i++) {
                    var c = this.vertices[i];
                    var x = c.x, y = c.y, z = c.z;

                    // rotation around X axis
                    var p = x;
                    var q = y * Math.cos(radX) 
                        - z * Math.sin(radX);
                    var r = y * Math.sin(radX) 
                        + z * Math.cos(radX);

                    // rotation around Y axis
                    var x = p * Math.cos(radY) 
                        + r * Math.sin(radY);
                    var y = q;
                    var z = -p * Math.sin(radY) 
                        + r * Math.cos(radY);
                    this.pos[i] = { x: x, y: y, z: z };
                }
            };

            this.ishit = function (x, y) {
                return this.x - this.w < x && x < this.x + this.w &&
                    this.y - this.h < y && y < this.y + this.h;
            };

            this.translate = function (dx, dy) {
                this.x += dx;
                this.y += dy;

                for (var i = 0 ; i < this.vertices.length ; i++) {
                    this.vertices[i].x += dx;
                    this.vertices[i].y += dy;
                }
            };
        }

        function init() {
            ctx = document.getElementById("field").getContext("2d");
            ctx.font = "20pt Arial";

            var colors = ['red', 'orange', 'yellow', 
                          'green', 'purple', 'blue'];
            for (var y = 0 ; y < colors.length ; y++) {
                for (var x = -3 ; x < 4 ; x++) {
                    var b = new Cube(x * 70, y * 50 + 450, 0,
                        30, 10, 5, colors[y]);
                    blocks.push(b);
                }
            }
            paddle = new Cube(0, 0, 0, 30, 10, 5, "white");
            blocks.push(paddle);

            ball = new Cube(0, 400, 0, 5, 5, 5, "yellow");
            blocks.push(ball);

            onkeydown = function (e) { keymap[e.keyCode] = true; }
            onkeyup = function (e) { keymap[e.keyCode] = false; }
            timer = setInterval(tick, 20)
        }

        function tick() {
            if (keymap[37]) { if (paddle.x > -250) paddle.translate(-5, 0) }
            if (keymap[39]) { if (paddle.x < +250) paddle.translate(5, 0) }

            // tilt 
            var radY = paddle.x / 1000;
            var radX = 0.5 + ball.y / 2000;
            blocks.forEach(function (b) { b.rotateXY(radX, radY) });

            // move ball
            var dx = Math.cos(deg2rad(theta)) * speed;
            var dy = Math.sin(deg2rad(theta)) * speed;
            ball.translate(dx, dy);

            var count = blocks.length;
            blocks = blocks.filter(function (b) {
                return b == ball || b == paddle || !b.ishit(ball.x, ball.y);
            });
            if (blocks.length != count) {
                theta = -theta;
            }
            if (blocks.length == 2) {
                stop("CLEARED")
            }

            if (ball.y > 800) {                     // hit ceiling?
                theta = -theta;
                speed = 10;
            }
            if (ball.y < -1200) {
                stop("GAME OVER");
            }
            if (ball.x < -250 || ball.x > 250) {    // hit left, right?
                theta = 180 - theta;
            }
            if (paddle.ishit(ball.x, ball.y)) {
                theta = 90 + ((paddle.x - ball.x) / paddle.w) * 80;
            }

            paint();
        }

        function stop(str) {
            message = str;
            clearInterval(timer);
            timer = NaN;
        }

        function paint() {
            // clear background
            ctx.fillStyle = "black";
            ctx.fillRect(0, 0, 600, 600);

            // draw blocks
            blocks.forEach(function (b) {
                ctx.strokeStyle = b.color;
                ctx.beginPath();
                for (var i = 0 ; i < b.polygons.length ; i++) {
                    for (var j = 0 ; j < 4 ; j++) {
                        var index = b.polygons[i][j];
                        var v = b.pos[index];
                        var x = v.x / (v.z + 500) * 500 + 300;
                        var y = -v.y / (v.z + 500) * 500 + 500;
                        if (j == 0) {
                            ctx.moveTo(x, y);
                        } else {
                            ctx.lineTo(x, y);
                        }
                    }
                    ctx.closePath();
                    ctx.stroke();
                }
            })

            if (isNaN(timer)) {
                ctx.fillStyle = "yellow";
                ctx.fillText(message, 220, 250);
            }
        }

        // mouse & touch work around
        addEventListener("touchstart", mymousedown);
        addEventListener("touchend", mymouseup);
        addEventListener("mousedown", mymousedown);
        addEventListener("mouseup", mymouseup);
        oncontextmenu = function(e) {e.preventDefault(); }
        function mymousedown(e){
            var mouseX = !isNaN(e.offsetX) ? e.offsetX : e.touches[0].clientX;
            if (mouseX < ctx.canvas.width / 2){
                keymap[37] = true;
            }else{
                keymap[39] = true;
            }
        } 
        function mymouseup(e){
            keymap = [];
        }           
    </script>
</head>
<body onload="init()">
    <canvas id="field" width="600" height="600" 
            style="width:600px; height:600px"></canvas>
</body>
</html>

""" 3D Blocks - Copyright 2016 Kenichiro Tanaka """
import sys
import random
from math import sin, cos, floor, radians
import pygame
from pygame.locals import QUIT, K_LEFT, K_RIGHT, KEYDOWN

pygame.init()
pygame.key.set_repeat(5, 5)
SURFACE = pygame.display.set_mode([600, 600])
FPSCLOCK = pygame.time.Clock()

class Cube:
    """ Cube for blocks and paddle """
    polygons = [
        [2, 1, 5, 6], [0, 1, 2, 3], [4, 5, 1, 0],
        [2, 6, 7, 3], [7, 6, 5, 4], [0, 3, 7, 4]
    ]

    def __init__(self, x, y, z, w, h, d, color):
        self.xpos = x
        self.ypos = y
        self.width = w
        self.height = h
        self.color = color
        self.pos = []
        self.vertices = [
            {"x": x - w, "y": y - h, "z": z + d},
            {"x": x - w, "y": y + h, "z": z + d},
            {"x": x + w, "y": y + h, "z": z + d},
            {"x": x + w, "y": y - h, "z": z + d},
            {"x": x - w, "y": y - h, "z": z - d},
            {"x": x - w, "y": y + h, "z": z - d},
            {"x": x + w, "y": y + h, "z": z - d},
            {"x": x + w, "y": y - h, "z": z - d},
        ]

    def set_camera(self, rad_x, rad_y):
        "update vertice positions depending on camera location"
        self.pos.clear()
        for vert in self.vertices:
            p0x = vert["x"]
            p0y = vert["y"]
            p0z = vert["z"]

            # rotate around X axis
            p1x = p0x
            p1y = p0y * cos(rad_x) - p0z * sin(rad_x)
            p1z = p0y * sin(rad_x) + p0z * cos(rad_x)

            # rotate around Y axis
            p2x = p1x * cos(rad_y) + p1z * sin(rad_y)
            p2y = p1y
            p2z = -p1x * sin(rad_y) + p1z * cos(rad_y)

            self.pos.append({"x": p2x, "y": p2y, "z": p2z})

    def ishit(self, xpos, ypos):
        "return if (x,y) hits the block"
        return self.xpos-self.width < xpos < self.xpos+self.width \
            and self.ypos-self.height < ypos < self.ypos+self.height

    def translate(self, diffx, diffy):
        "move the block"
        self.xpos += diffx
        self.ypos += diffy
        for vert in self.vertices:
            vert["x"] += diffx
            vert["y"] += diffy

def tick():
    """ called periodically from the main loop """
    global SPEED, THETA, BLOCKS, MESSAGE
    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            sys.exit()
        elif event.type == KEYDOWN:
            if event.key == K_LEFT:
                PADDLE.translate(-10, 0)
            elif event.key == K_RIGHT:
                PADDLE.translate(+10, 0)

    if not MESSAGE is None:
        return

    # move the ball
    diffx = cos(radians(THETA)) * SPEED
    diffy = sin(radians(THETA)) * SPEED
    BALL.translate(diffx, diffy)

    # hit any blocks?
    count = len(BLOCKS)
    BLOCKS = [x for x in BLOCKS if x == BALL or x == PADDLE \
        or not x.ishit(BALL.xpos, BALL.ypos)]

    if len(BLOCKS) != count:
        THETA = -THETA

    # hit ceiling, wall or paddle?
    if BALL.ypos > 800:
        THETA = -THETA
        SPEED = 10
    if BALL.xpos < -250 or BALL.xpos > 250:
        THETA = 180 - THETA
    if PADDLE.ishit(BALL.xpos, BALL.ypos):
        THETA = 90 + ((PADDLE.xpos - BALL.xpos) / PADDLE.width) * 80
    if BALL.ypos < -1200 and len(BLOCKS) > 2:
        MESSAGE = MESS1
    if len(BLOCKS) == 2:
        MESSAGE = MESS0

    # Rotate the Cube
    rad_y = PADDLE.xpos / 1000
    rad_x = 0.5 + BALL.ypos / 2000
    for block in BLOCKS:
        block.set_camera(rad_x, rad_y)

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

    # Paint polygons
    for block in BLOCKS:
        for indices in block.polygons:
            poly = []
            for index in indices:
                pos = block.pos[index]
                zpos = pos["z"] + 500
                xpos = pos["x"] * 500 / zpos + 300
                ypos = -pos["y"] * 500 / zpos + 500
                poly.append((xpos, ypos))
            pygame.draw.lines(SURFACE, block.color, True, poly)

    if not MESSAGE is None:
        SURFACE.blit(MESSAGE, (150, 400))
    pygame.display.update()

FPS = 40
SPEED = 5
THETA = 270 + floor(random.randint(-10, 10))
BLOCKS = []
BALL = Cube(0, 400, 0, 5, 5, 5, (255, 255, 0))
PADDLE = Cube(0, 0, 0, 30, 10, 5, (255, 255, 255))
MESSAGE = None
MYFONT = pygame.font.SysFont(None, 80)
MESS0 = MYFONT.render("Cleared!!!", True, (255, 255, 0))
MESS1 = MYFONT.render("Game Over!", True, (255, 255, 0))

def main():
    """ main routine """
    colors = [(255, 0, 0), (255, 165, 0), (242, 242, 0),
              (0, 128, 0), (128, 0, 128), (0, 0, 250)]

    for ypos in range(0, len(colors)):
        for xpos in range(-3, 4):
            block = Cube(xpos * 70, ypos * 50 + 450, 0,
                         30, 10, 5, colors[ypos])
            BLOCKS.append(block)

    BLOCKS.append(PADDLE)
    BLOCKS.append(BALL)

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

if __name__ == '__main__':
    main()