空間遊泳

三次元空間内を、ふんわり、たゆたう。

画面説明

実行方法

操作

ソースコード


"""
空間遊泳
第1.0版 2025.4.2
第1.1版 2025.4.3 移動時加速の調整、ビットマップ縮小レベルの調整

MIT License

Copyright (c) 2025  Current Color Co. Ltd.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
import pygame
import math
import random
import numpy
import datetime
import gc

# 画面設定
G_WIDTH = 800
G_HEIGHT = 600
pygame.display.set_caption("空間遊泳")

# 空間設定
S_WIDTH = 500
S_HEIGHT = 500
S_DEPTH = 1000

# 色の定義
C_BLACK = (0, 0, 0)
C_WHITE = (255, 255, 255)
C_YELLOW = (255, 255, 0)
C_GRAY = (128, 128, 128)
C_BLUE = (0, 255, 255)
C_RED = (255, 0, 0)

# オブジェクトの種類
OBJECT_TYPE_CIRCLE = 0
OBJECT_TYPE_TRIANGLE = 1
OBJECT_TYPE_LINE = 2
OBJECT_TYPE_RECTANGLE = 3
OBJECT_TYPE_TEXT = 4
OBJECT_TYPE_BITMAP = 5
OBJECT_TYPE_TETRAHEDRON = 6
OBJECT_TYPE_PILLAR = 7

# グローバル変数


######################################
# 3D から 2D への変換関数
def project_point(x1, y1, z1, xc, yc, zc, rot_UD, rot_RL, rot_Z):
    # カメラ基準の座標系に変換
    dx, dy, dz = x1 - xc, y1 - yc, z1 - zc
    # 左右・上下・Z軸の回転
    cos_r, sin_r = math.cos(math.radians(rot_RL)), math.sin(math.radians(rot_RL))
    dx, dz = cos_r * dx - sin_r * dz, sin_r * dx + cos_r * dz
    cos_u, sin_u = math.cos(math.radians(rot_UD)), math.sin(math.radians(rot_UD))
    dy, dz = cos_u * dy - sin_u * dz, sin_u * dy + cos_u * dz
    cos_z, sin_z = math.cos(math.radians(rot_Z)), math.sin(math.radians(rot_Z))
    dx, dy = cos_z * dx - sin_z * dy, sin_z * dx + cos_z * dy
    
    if dz <= 0:   #カメラより後ろのオブジェクト
        return None, None, None, None
    
    # 透視投影
    fov = 500  # 視野のスケール
    x2 = int(G_WIDTH / 2 + (dx / dz) * fov)
    y2 = int(G_HEIGHT / 2 - (dy / dz) * fov)
    d = math.sqrt(dx**2 + dy**2 + dz**2)
    r = fov / dz
    
    return x2, y2, d, r

######################################
# 三角形の裏表判定
# 三角形の表側がカメラ から見えているか判定する。
# 三角形の頂点は表側から見た時に右回りに定義する。
def is_triangle_visible(points_3d, camera):
    (x1, y1, z1) = points_3d[0]
    (x2, y2, z2) = points_3d[1]
    (x3, y3, z3) = points_3d[2]
    xc = camera[0]
    yc = camera[1]
    zc = camera[2]

    v1 = numpy.array([x2 - x1, y2 - y1, z2 - z1])
    v2 = numpy.array([x3 - x1, y3 - y1, z3 - z1])
    normal = numpy.cross(v1, v2) #三角形の法線ベクトル(外積)
    xg = (x1 + x2 + x3) / 3
    yg = (y1 + y2 + y3) / 3
    zg = (z1 + z2 + z3) / 3
    view_vector = numpy.array([xg - xc, yg - yc, zg - zc]) #視線ベクトル
    dot_product = numpy.dot(normal, view_vector) #内積

    return dot_product <= 0  # Trueなら表側

######################################
# 四角形の裏表判定
# 四角形の表側がカメラ から見えているか判定する。
# 四角形の頂点は同一平面内で表側から見た時に右回りに定義する。
def is_rectangle_visible(points_3d, camera):
    (x1, y1, z1) = points_3d[0]
    (x2, y2, z2) = points_3d[1]
    (x3, y3, z3) = points_3d[2]
    (x4, y4, z4) = points_3d[3]
    xc = camera[0]
    yc = camera[1]
    zc = camera[2]

    v1 = numpy.array([x2 - x1, y2 - y1, z2 - z1])
    v2 = numpy.array([x3 - x1, y3 - y1, z3 - z1])
    normal = numpy.cross(v1, v2) #三角形の法線ベクトル(外積)
    xg = (x1 + x2 + x3 + x4) / 4
    yg = (y1 + y2 + y3 + y4) / 4
    zg = (z1 + z2 + z3 + z4) / 4
    view_vector = numpy.array([xg - xc, yg - yc, zg - zc]) #視線ベクトル
    dot_product = numpy.dot(normal, view_vector) #内積

    return dot_product <= 0  # Trueなら表側

######################################
# 多角形の描画(3D→2D)
def draw_polygon(screen, points_3d, camera, color, line=None):
 
    points_2d = []
    for (x, y, z) in points_3d:
        x1, y1, d, r = project_point(x, y, z, *camera)
        if x1 is None:
            return
        else:
            points_2d.append((x1,y1))
    if line is not None:
        pygame.draw.polygon(screen, color, points_2d, line)
    else:
        pygame.draw.polygon(screen, color, points_2d)

######################################
# ビットマップ(Surface)のキャッシュ管理クラス
class ImageCache:
    def __init__(self):
        self.cache = {}
        self.levels = numpy.linspace(0,1.1,25)**1.8

    def get_transformed_image(self, img, type, scale, angle):
        #回転角、拡大率の離散化
        angle = round(angle/7.5)*7.5 %360
        scale = min(self.levels, key=lambda x:abs(x - scale))
        
        key = (type, scale, angle)
        if key in self.cache:
            return self.cache[key]
        img1 = pygame.transform.scale(img,
                       (int(img.get_width() * scale), int(img.get_height() * scale)))
        img2 = pygame.transform.rotate(img1, angle)
        self.cache[key] = img2
        del img1
        return img2

    def clear(self):
        self.cache.clear()
        gc.collect()    

    def clear_type(self, type):
        keys_to_delete = [key for key in self.cache if key[0] == type]
        for key in keys_to_delete:
            del self.cache[key]
        gc.collect()   

######################################
# オブジェクトのクラス
class OBJECT:

    # オブジェクトの生成
    def __init__(self, type):
        self.type = type
        self.x = 0
        self.y = 0
        self.vx = 0
        self.vy = 0
        self.z = S_DEPTH
        self.color = C_BLACK
        self.image = pygame.image.load("image.png")
        self.text = ""

        if self.type == OBJECT_TYPE_CIRCLE:
            self.x = random.randint(-500,500)
            self.y = random.randint(200,500)
            self.color = (128,196,255)

        elif self.type == OBJECT_TYPE_TRIANGLE:
            self.x = random.randint(-500,500)
            self.y = random.randint(100,200)
            self.color = (255,64,64)
            self.cycle = 0
            self.x_center = self.x

        elif self.type == OBJECT_TYPE_TETRAHEDRON:
            self.x = random.randint(-250,250)
            self.y = random.randint(0,100)
            self.color = (128,255,128)

        elif self.type == OBJECT_TYPE_TEXT:
            self.x = 0
            self.y = 200
            self.color = (255,255,128)
            self.font = pygame.font.SysFont("Courier", 48)

        elif self.type == OBJECT_TYPE_BITMAP:
            self.x = random.randint(-300,300)
            self.y = random.randint(-100,300)

        elif self.type == OBJECT_TYPE_LINE:
            self.color = (255,255,255)
            self.x = 0
            self.y = -S_HEIGHT

        elif self.type == OBJECT_TYPE_PILLAR:
            self.color = (255,255,255)
            self.x = random.randint(-300,300)
            self.y = 0
            self.height = random.randint(-100,-30)

    # オブジェクトの移動
    def move(self, dz, tx, ty):

        #オフセット
        self.z += dz

        if self.type == OBJECT_TYPE_CIRCLE:
            pass
        elif self.type == OBJECT_TYPE_TRIANGLE:
            self.cycle += 10
            if self.cycle > 360:
                 self.cycle -= 360
            self.x = self.x_center + 30*math.cos(math.radians(self.cycle))
        elif self.type == OBJECT_TYPE_TETRAHEDRON:
            self.z -= 15
        elif self.type == OBJECT_TYPE_TEXT:
            pass
        elif self.type == OBJECT_TYPE_BITMAP:
            a = 0.3 #加速度
            d = 50 #非加速範囲
            self.vx += a if self.x < tx - d else (-a if self.x > tx + d else 0)
            self.vy += a if self.y < ty - d else (-a if self.y > ty + d else 0)
            self.vx, self.vy = self.vx * 0.95, self.vy *0.95
            self.x += self.vx
            self.y += self.vy
            self.z -= 8
        elif self.type == OBJECT_TYPE_LINE:
            pass
        elif self.type == OBJECT_TYPE_PILLAR:        
            pass

    # オブジェクトの描画
    def draw(self, screen, x2, y2, d, r, camera, imgc):

        if self.type == OBJECT_TYPE_CIRCLE:
            cr = (S_DEPTH - self.z)/S_DEPTH
            c = tuple(element * cr for element in self.color)
            pygame.draw.circle(screen, c, (x2, y2), max(2, int(30 * r)))

        elif self.type == OBJECT_TYPE_TRIANGLE:
            cr = (S_DEPTH - self.z)/S_DEPTH
            c = tuple(element * cr for element in self.color)
            points_3d = [(self.x - 30, self.y, self.z),
                            (self.x + 30, self.y, self.z),
                            (self.x, self.y -60, self.z)]
            draw_polygon(screen, points_3d, camera, c)

        elif self.type == OBJECT_TYPE_TETRAHEDRON:
            s = 100
            h = (math.sqrt(6) / 3) * s
            z_min = self.z - (3/4)*h
            z_max = self.z + (1/4)*h
            v1 = (self.x, self.y, z_min)
            v2 = (self.x-s/2, self.y - s/(2*math.sqrt(3)), z_max)
            v3 = (self.x+s/2, self.y - s/(2*math.sqrt(3)), z_max)
            v4 = (self.x, self.y + s / math.sqrt(3), z_max)

            """
            points_3d = [v4, v2, v3]
            if is_triangle_visible(points_3d, camera):
                cr = (S_DEPTH - self.z)/S_DEPTH * 0.5
                c = tuple(element * cr for element in self.color)
                draw_polygon(screen, points_3d, camera, c)
            """

            points_3d = [v4, v1, v2]
            if is_triangle_visible(points_3d, camera):
                cr = (S_DEPTH - self.z)/S_DEPTH
                c = tuple(element * cr for element in self.color)
                draw_polygon(screen, points_3d, camera, c)

            points_3d = [v1, v4, v3]
            if is_triangle_visible(points_3d, camera):
                cr = (S_DEPTH - self.z)/S_DEPTH * 0.8
                c = tuple(element * cr for element in self.color)
                draw_polygon(screen, points_3d, camera, c)

            points_3d = [v1, v3, v2]
            if is_triangle_visible(points_3d, camera):
                cr = (S_DEPTH - self.z)/S_DEPTH * 0.7
                c = tuple(element * cr for element in self.color)
                draw_polygon(screen, points_3d, camera, c)
 
        elif self.type == OBJECT_TYPE_TEXT:

            text = str(datetime.datetime.now().strftime("%Y-%m-%d %H:%M"))
            if self.text != text:
                self.text = text
                self.image = self.font.render(text, True, C_YELLOW)
                imgc.clear_type(OBJECT_TYPE_TEXT)
            img = imgc.get_transformed_image(self.image,
                         OBJECT_TYPE_TEXT, r * 0.2, 0)
            if self.z > S_DEPTH /2:
                cr = (S_DEPTH - self.z)/(S_DEPTH / 2)
                img.set_alpha(cr *255) 
            rect = img.get_rect()
            rect.center = (x2, y2)
            screen.blit(img,  rect)

        elif self.type == OBJECT_TYPE_BITMAP:
            r1 = r * 0.2
            img = imgc.get_transformed_image(self.image, 
                           OBJECT_TYPE_BITMAP, r1, camera[5])
            if self.z > S_DEPTH /2:
                cr = (S_DEPTH - self.z)/(S_DEPTH / 2)
                img.set_alpha(cr *255) 
            rect = img.get_rect()
            rect.center = (x2, y2)
            screen.blit(img,  rect) 

        elif self.type == OBJECT_TYPE_LINE:
            cr = (S_DEPTH - self.z)/S_DEPTH
            c = tuple(element * cr for element in self.color)
            points_3d = [(self.x - S_WIDTH, self.y, self.z),
                                (self.x + S_WIDTH, self.y, self.z)]
            draw_polygon(screen, points_3d, camera, c, 5)

        elif self.type == OBJECT_TYPE_PILLAR:
            cr = (S_DEPTH - self.z)/S_DEPTH
            c = tuple(element * cr for element in self.color)

            size = 20
            x_min = self.x - size
            x_max = self.x + size
            z_min = self.z - size
            z_max = self.z + size
            y_min = -S_HEIGHT * 0.7
            y_max = self.height

            v1 = (x_min, y_max, z_max)
            v2 = (x_max, y_max, z_max)
            v3 = (x_max, y_max, z_min)
            v4 = (x_min, y_max, z_min)
            v5 = (x_min, y_min, z_max)
            v6 = (x_max, y_min, z_max)
            v7 = (x_max, y_min, z_min)
            v8 = (x_min, y_min, z_min)

            """
            points_3d = [v2, v1, v5, v6] #背面
            if is_triangle_visible(points_3d, camera):
                cr = (S_DEPTH - self.z)/S_DEPTH * 0.8
                c = tuple(element * cr for element in self.color)
                draw_polygon(screen, points_3d, camera, c)
            """

            points_3d = [v3, v2, v6, v7] #右面
            if is_triangle_visible(points_3d, camera):
                cr = (S_DEPTH - self.z)/S_DEPTH * 0.8
                c = tuple(element * cr for element in self.color)
                draw_polygon(screen, points_3d, camera, c)

            points_3d = [v4, v3, v7, v8] #前面
            if is_triangle_visible(points_3d, camera):
                cr = (S_DEPTH - self.z)/S_DEPTH * 0.9
                c = tuple(element * cr for element in self.color)
                draw_polygon(screen, points_3d, camera, c)

            points_3d = [v1, v4, v8, v5] #左面
            if is_triangle_visible(points_3d, camera):
                cr = (S_DEPTH - self.z)/S_DEPTH
                c = tuple(element * cr for element in self.color)
                draw_polygon(screen, points_3d, camera, c)

            points_3d = [v1, v2, v3, v4] #上面
            if is_triangle_visible(points_3d, camera):
                cr = (S_DEPTH - self.z)/S_DEPTH * 0.7
                c = tuple(element * cr for element in self.color)
                draw_polygon(screen, points_3d, camera, c)

            points_3d = [v6, v5, v8, v7] #下面
            if is_triangle_visible(points_3d, camera):
                cr = (S_DEPTH - self.z)/S_DEPTH * 0.6
                c = tuple(element * cr for element in self.color)
                draw_polygon(screen, points_3d, camera, c)

######################################
# 背景のクラス
class BACKGROUND:

    # 背景の描画
    def draw(self, screen, camera):

        points_3d = [(-S_WIDTH, -S_HEIGHT, S_DEPTH * 2),
                            ( S_WIDTH, -S_HEIGHT, S_DEPTH *2),
                            ( S_WIDTH,  S_HEIGHT, S_DEPTH *2),
                            (-S_WIDTH,  S_HEIGHT, S_DEPTH *2)]
        draw_polygon(screen, points_3d, camera, (128,128,128), 1)

######################################
# 自己視点のクラス
class PLAYER:

    def __init__(self):
        self.vx = 0
        self.vy = 0
        self.v_max = 10
        self.v_attenu = 0.9
        self.rz_max = 45
        self.rx_mx = 30
        self.ry_max = 30

    def move(self, ax, ay, camera):
        x,y = camera[0],camera[1]
        rx,ry,rz = camera[4],camera[3],camera[5]

        self.vx += ax
        self.vx *= self.v_attenu
        self.vx = min(self.v_max, self.vx)
        self.vx = max(-self.v_max, self.vx)

        self.vy += ay
        self.vy *= self.v_attenu
        self.vy = min(self.v_max, self.vy)
        self.vy = max(-self.v_max, self.vy)

        rx = self.vx * 5
        rz = self.vx * 3
        ry = self.vy * 5
        x += self.vx
        y += self.vy
        camera[0],camera[1] = x,y
        camera[4],camera[3],camera[5] = rx, ry, rz

######################################
# メイン関数
def main():

    # 初期化
    pygame.init()

    screen = pygame.display.set_mode((G_WIDTH, G_HEIGHT))
    clock = pygame.time.Clock()
    camera = [0,0,0,0,0,0] #[x,y,z, up_down, right_left, z]
    objects = []
    bg = BACKGROUND()
    player = PLAYER()
    imgc = ImageCache()

    gen_types = [OBJECT_TYPE_CIRCLE, 
                         OBJECT_TYPE_TRIANGLE, 
                         OBJECT_TYPE_TETRAHEDRON, 
                         OBJECT_TYPE_LINE, 
                         OBJECT_TYPE_TEXT,
                         OBJECT_TYPE_PILLAR,
                         OBJECT_TYPE_BITMAP]
    gen_counts = [0,0,0,0,0,0,0]
    gen_periods = [3,10,15,30,30,10,20]

    running = True
    while running:
        screen.fill(C_BLACK)
    
        # 入力処理
        keys = pygame.key.get_pressed()
        if keys[pygame.K_g]: camera[0] -= 10
        if keys[pygame.K_j]: camera[0] += 10
        if keys[pygame.K_y]: camera[1] += 10
        if keys[pygame.K_h]: camera[1] -= 10
        if keys[pygame.K_a]: camera[4] = max(-45, camera[4] - 1)
        if keys[pygame.K_d]: camera[4] = min(45, camera[4] + 1)
        if keys[pygame.K_w]: camera[3] = min(45, camera[3] + 1)
        if keys[pygame.K_s]: camera[3] = max(-45, camera[3] - 1)
        if keys[pygame.K_x]: camera[5] -= 1
        if keys[pygame.K_c]: camera[5] += 1
        if keys[pygame.K_r]: camera[2] += 23
        if keys[pygame.K_f]: camera[2] -= 23
        if keys[pygame.K_q]: camera = [0, 0, 0, 0, 0, 0]

        ax, ay = 0,0
        accel = 0.3
        if keys[pygame.K_LEFT]: ax = -accel
        if keys[pygame.K_RIGHT]: ax = accel
        if keys[pygame.K_UP]: ay = accel
        if keys[pygame.K_DOWN]: ay = -accel
 
        # 背景の描画
        bg.draw(screen, camera)

        # オブジェクトの生成
        for i, type in enumerate(gen_types):
            gen_counts[i] -= 1
            if gen_counts[i] <= 0:
                gen_counts[i] = gen_periods[i]
                objects.append(OBJECT(type))

        # オブジェクトの移動と削除
        speed = -5
        for object in reversed(objects):
            object.move(speed, camera[0], camera[1])
            if object.z < 100:
                objects.remove(object)

        # カメラ位置の移動制御
        player.move(ax,ay,camera)

        # オブジェクトの描画 (遠い順にソート)
        projected_objects = []
        for object in objects:
            x2, y2, d, r = project_point(object.x, object.y, object.z, *camera)
            if x2 is not None:
                projected_objects.append([object, x2, y2, d, r])
        projected_objects.sort(reverse=True, key=lambda x: x[3])
    
        for object, x2, y2, d, r in projected_objects:
            object.draw(screen, x2, y2, d, r, camera, imgc)
 
        pygame.display.flip()
        clock.tick(30)
    
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            
pygame.quit()

if __name__ == "__main__":
    main()