Created
December 15, 2021 10:06
-
-
Save RyuaNerin/09ffe28412081ddd9fd4c848e8c19205 to your computer and use it in GitHub Desktop.
tkinter 를 이용한 간단한 게임.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/python3 | |
# -*- coding: utf-8 -*- | |
import enum | |
import math | |
import random | |
import threading | |
import time | |
import tkinter as tk | |
import typing | |
from typing import Final as const | |
#################################################################################################### | |
# 좌표, 크기, 바운딩 등을 직관화 하기 위한 부분 | |
class Point(typing.NamedTuple): | |
x: float | |
y: float | |
def __add__(self, p): | |
return Point(self.x + p.x, self.y + p.y) | |
def __sub__(self, p): | |
return Point(self.x - p.x, self.y - p.y) | |
class Size(typing.NamedTuple): | |
w: float | |
h: float | |
class Bounds(typing.NamedTuple): | |
left: float | |
top: float | |
right: float | |
bottom: float | |
class Text: | |
def __init__( | |
self, | |
text: typing.Union[str, typing.Callable[[typing.Any], str]], | |
font: typing.Tuple[str, str, str], | |
point: Point, | |
anchor: str = "s", | |
color: typing.Optional[ | |
typing.Union[str, typing.Callable[[typing.Any], str]] | |
] = None, | |
) -> None: | |
self.text = text | |
self.font = font | |
self.point = Point(*point) | |
self.color = color | |
self.anchor = anchor | |
def to_bounds(center: Point, size: Size) -> Bounds: | |
"""사각형 바운딩 크기를 계산하는 함수 | |
Args: | |
center: 중앙 좌표 | |
size : 크기 | |
Returns: | |
Bounds: 바운딩 (left, top, right, bottom 순) | |
""" | |
center = Point(*center) | |
size = Size(*size) | |
return Bounds( | |
center.x - size.w / 2, | |
center.y - size.h / 2, | |
center.x + size.w / 2, | |
center.y + size.h / 2, | |
) | |
#################################################################################################### | |
""" | |
상수 영역 | |
- 기준점이 별도로 설정되어 있지 않으면 중앙 기준 | |
""" | |
PI0: const = 0 | |
PI1: const = math.pi / 2 | |
PI2: const = math.pi | |
PI3: const = math.pi / 2 * 3 | |
PI4: const = math.pi * 2 | |
FPS: const = 60 | |
GAME_TIME: const = 90 # 게임 진행 시간 | |
BALL_WAIT_TIME: const = 2 # 공 출발 전 기다려주는 시간 | |
BALL_MOVE_MIN: const = 1.0 # 이동 계산을 할 최소 길이 | |
SLEEP_WHEN_SCORE: const = 2 # 점수 발생 시 잠시 기다릴 시간 | |
# -------------------------------------------------------------------------------- | |
WINDOW_SIZE: const = Size(600, 400) | |
WINDOW_BACKGROUND: const = "#FFFFFF" | |
# -------------------------------------------------------------------------------- | |
WALL_COLOR: const = "#BDBDBD" | |
GOAL_COLOR: const = "#FFCC80" | |
WALL_THICKNESS: const = 32 | |
GOAL_SIZE: const = Size(WALL_THICKNESS / 2, 150) | |
GOAL_BOUNDS_P1: const = to_bounds( | |
(WINDOW_SIZE.w - GOAL_SIZE.w / 2, WINDOW_SIZE.h / 2), GOAL_SIZE | |
) | |
GOAL_BOUNDS_P2: const = to_bounds((GOAL_SIZE.w / 2, WINDOW_SIZE.h / 2), GOAL_SIZE) | |
WALL_HORZ_SIZE: const = Size(WINDOW_SIZE.w - WALL_THICKNESS * 2, WALL_THICKNESS) | |
WALL_VERT_SIZE: const = Size( | |
WALL_THICKNESS, (WINDOW_SIZE.h - WALL_THICKNESS * 2 - GOAL_SIZE.h) / 2 | |
) | |
WALL_TOP_BOUNDS: const = to_bounds( | |
(WINDOW_SIZE.w / 2, WALL_THICKNESS / 2), WALL_HORZ_SIZE | |
) | |
WALL_BOTTOM_BOUNDS: const = to_bounds( | |
(WINDOW_SIZE.w / 2, WINDOW_SIZE.h - WALL_THICKNESS / 2), WALL_HORZ_SIZE | |
) | |
WALL_LEFT_TOP_BOUNDS: const = to_bounds( | |
(WALL_THICKNESS / 2, WALL_THICKNESS + WALL_VERT_SIZE.h / 2), WALL_VERT_SIZE | |
) | |
WALL_LEFT_BOTTOM_BOUNDS: const = to_bounds( | |
(WALL_THICKNESS / 2, WINDOW_SIZE.h - WALL_THICKNESS - WALL_VERT_SIZE.h / 2), | |
WALL_VERT_SIZE, | |
) | |
WALL_RIGHT_TOP_BOUNDS: const = to_bounds( | |
(WINDOW_SIZE.w - WALL_THICKNESS / 2, WALL_THICKNESS + WALL_VERT_SIZE.h / 2), | |
WALL_VERT_SIZE, | |
) | |
WALL_RIGHT_BOTTOM_BOUNDS: const = to_bounds( | |
( | |
WINDOW_SIZE.w - WALL_THICKNESS / 2, | |
WINDOW_SIZE.h - WALL_THICKNESS - WALL_VERT_SIZE.h / 2, | |
), | |
WALL_VERT_SIZE, | |
) | |
# 중앙쪽에 벽을 배치 | |
WALL_ADDITIONAL_LIST = [ | |
to_bounds(Point(WINDOW_SIZE.w / 2, WINDOW_SIZE.h / 2 - 100), (30, 30)), | |
to_bounds(Point(WINDOW_SIZE.w / 2, WINDOW_SIZE.h / 2 + 100), (30, 30)), | |
] | |
# -------------------------------------------------------------------------------- | |
BALL_COLOR: const = "#EF5350" | |
BALL_COLOR_BOUNCE: const = "#EF9A9A" | |
BALL_COLOR_HIGHLIGHT: const = ["#B71C1C", "#EF5350"] | |
BALL_BOUNCE_COLOR_TIME: const = 0.1 # 공이 부딛혔을때 색이 잠깐 변하는 시간 | |
BALL_RADIUS: const = 10 # 공의 반지름 | |
BALL_SIZE: const = Size(BALL_RADIUS * 2, BALL_RADIUS * 2) | |
BALL_POS_INIT: const = Point(WINDOW_SIZE.w / 2, WINDOW_SIZE.h / 2) # 공의 초기 위치 | |
BALL_SPEED_INIT: const = 250 # 공의 기본 이동 속도 | |
BALL_SPEED_ADDITIONAL: const = 10 # 공이 벽에 부딛힐 때 마다 빨라지는 속도 | |
BALL_ANGLE_INIT_RANGE: const = PI1 * 2 / 5 # 공이 처음 출발할 때 제한할 각도. | |
BALL_ANGLE_VERT_LIMIT: const = PI1 / 6 # 너무 세로로만 이동하면 각도의 보정을 수행 | |
# -------------------------------------------------------------------------------- | |
BAR_P1_COLOR: const = ["#26C6DA", "#00ACC1"] | |
BAR_P2_COLOR: const = ["#66BB6A", "#43A047"] | |
BAR_SIZE: const = Size(5, 60) | |
BAR_Y_INIT: const = WINDOW_SIZE.h / 2 | |
BAR_Y_RANGE: const = ( | |
WALL_THICKNESS + BAR_SIZE[1] / 2, | |
WINDOW_SIZE.h - BAR_SIZE[1] / 2 - WALL_THICKNESS, | |
) | |
BAR_P1_X: const = 50 | |
BAR_P2_X: const = WINDOW_SIZE.w - BAR_P1_X | |
BAR_SPEED_PER_SEC: const = 400 # 1초당 움직이는 픽셀 수 | |
BAR_BALL_REBOUND_ANGLE: const = PI1 # 바 끝에서 맞은 경우 반사되는 각도. 공이 바 중앙에 맞을때는 0을 기준(수평)으로 함. | |
BAR_BALL_REBOUND_ACCELERATION: const = 0.5 # 바 끝에서 맞은 경우 반사되는 추가 속도 배율. 공이 바 중앙에 맞을때는 x1 | |
BAR_GLOW_TIME = 0.1 | |
# -------------------------------------------------------------------------------- | |
RENDER_MAIN_TITLE: const = Text( | |
text="Simple Game", font=("맑은 고딕", "40", "bold"), point=(WINDOW_SIZE.w / 2, 20), anchor="n" | |
) | |
RENDER_MAIN_NAME: const = Text( | |
text="By RyuaNerin", | |
font=("맑은 고딕", "10", "bold"), | |
point=(WINDOW_SIZE.w - 50, 100), | |
anchor="ne", | |
) | |
RENDER_MAIN_KEY: const = Text( | |
text="""- 단축키 - | |
Player 1 : q, a | |
Player 2 : UP, DOWN | |
일시정지 : ESC | |
공 이동경로 표시 : T | |
- 게임 규칙 - | |
공은 반사될 때 마다 조금씩 빨라집니다. | |
공이 각 플레이어의 막대기 중앙에 부딛힐 수록 직선으로, 빠르게 튕깁니다.""", | |
font=("맑은 고딕", "10", "bold"), | |
point=(10, WINDOW_SIZE.h / 2), | |
anchor="w", | |
) | |
RENDER_MAIN_HOWTOSTART: const = Text( | |
text="게임을 시작하려면 엔터키를 눌러주세요", | |
font=("맑은 고딕", "15", "bold"), | |
point=(WINDOW_SIZE.w / 2, WINDOW_SIZE.h / 2 + 150), | |
) | |
# -------------------------------------------------------------------------------- | |
RENDER_GAME_SCORE_DESC_P1: const = Text( | |
text="Player 1", | |
font=("맑은 고딕", "10", "bold"), | |
point=(WINDOW_SIZE.w / 2 - 100, WALL_THICKNESS + 20), | |
) | |
RENDER_GAME_SCORE_DESC_P2: const = Text( | |
text="Player 2", | |
font=("맑은 고딕", "10", "bold"), | |
point=(WINDOW_SIZE.w / 2 + 100, WALL_THICKNESS + 20), | |
) | |
RENDER_GAME_SCORE_VALUE_P1: const = Text( | |
text="", | |
font=("맑은 고딕", "20", "bold"), | |
point=(WINDOW_SIZE.w / 2 - 100, WALL_THICKNESS + 60), | |
) | |
RENDER_GAME_SCORE_VALUE_P2: const = Text( | |
text="", | |
font=("맑은 고딕", "20", "bold"), | |
point=(WINDOW_SIZE.w / 2 + 100, WALL_THICKNESS + 60), | |
) | |
RENDER_GAME_SEC_DESC: const = Text( | |
text="남은 시간", | |
font=("맑은 고딕", "10", "bold"), | |
point=(WINDOW_SIZE.w / 2, WALL_THICKNESS + 20), | |
) | |
RENDER_GAME_SEC_VALUE: const = Text( | |
text=lambda x: f"{x}", | |
font=("맑은 고딕", "30", "bold"), | |
color=lambda x: ["#000000", "#F44336"][int(x * 5) % 2], | |
point=(WINDOW_SIZE.w / 2, WALL_THICKNESS + 60), | |
) | |
RENDER_BALL_MOVE_COUNT: const = Text( | |
text=lambda x: f"{int(x)}", | |
font=("맑은 고딕", "20", "bold"), | |
color="#3E2723", | |
point=(WINDOW_SIZE.w / 2, WINDOW_SIZE.h / 2), | |
) | |
# -------------------------------------------------------------------------------- | |
RENDER_WIN_TITLE: const = Text( | |
text=lambda x: ["무승부", "Player1 승리", "Player2 승리"][x], | |
font=("맑은 고딕", "40", "bold"), | |
point=(WINDOW_SIZE.w / 2, WINDOW_SIZE.h / 2), | |
) | |
RENDER_WIN_SCORE: const = Text( | |
text=lambda x: f"{x[0]} VS {x[1]}", | |
font=("맑은 고딕", "20", "bold"), | |
point=(WINDOW_SIZE.w / 2, WINDOW_SIZE.h / 2 + 50), | |
) | |
RENDER_WIN_RESTART: const = Text( | |
text="처음으로 돌아가려면 ESC 를 눌러주세요", | |
font=("맑은 고딕", "15", "bold"), | |
point=(WINDOW_SIZE.w / 2, WINDOW_SIZE.h / 2 + 150), | |
) | |
# -------------------------------------------------------------------------------- | |
RENDER_PAUSE: const = Text( | |
text="일시정지", | |
font=("맑은 고딕", "30", "bold"), | |
point=(WINDOW_SIZE.w / 2, WINDOW_SIZE.h / 2), | |
) | |
# -------------------------------------------------------------------------------- | |
KEYCODE_P1_UP: const = ord("Q") | |
KEYCODE_P1_DOWN: const = ord("A") | |
KEYCODE_P2_UP: const = 38 # UP | |
KEYCODE_P2_DOWN: const = 40 # DOWN | |
KEYCODE_PAUSE: const = 27 # ESC | |
KEYCODE_START: const = 13 # ENTER | |
#################################################################################################### | |
class KeyStatus(enum.Enum): | |
NORMAL = 0 | |
UP = 1 | |
DOWN = 2 | |
class MainStatus(enum.Enum): | |
Main = 0 | |
Game = 1 | |
Pause = 2 | |
Win = 3 | |
class Game: | |
def __init__(self) -> None: | |
self._win = tk.Tk() | |
self._win.title("Simple Game") | |
self._win.geometry(f"{WINDOW_SIZE.w}x{WINDOW_SIZE.h}") | |
self._win.minsize(WINDOW_SIZE.w, WINDOW_SIZE.h) | |
self._win.maxsize(WINDOW_SIZE.w, WINDOW_SIZE.h) | |
self._canvas = tk.Canvas( | |
self._win, | |
width=WINDOW_SIZE.w, | |
height=WINDOW_SIZE.h, | |
background=WINDOW_BACKGROUND, | |
) | |
self._canvas.pack(fill="both", expand=True) | |
self._win.bind("<KeyPress>", self._win_key_press) | |
self._win.bind("<KeyRelease>", self._win_key_release) | |
self._canvas_id_ball_count = None # 공 이동 카운트다운 | |
self._game_main() | |
threading.Thread(target=self._animation, daemon=True).start() | |
def run(self): | |
self._win.mainloop() | |
def _win_key_press(self, e: tk.Event): | |
if e.keycode == KEYCODE_P1_UP: | |
self._p1_status = KeyStatus.UP | |
elif e.keycode == KEYCODE_P1_DOWN: | |
self._p1_status = KeyStatus.DOWN | |
elif e.keycode == KEYCODE_P2_UP: | |
self._p2_status = KeyStatus.UP | |
elif e.keycode == KEYCODE_P2_DOWN: | |
self._p2_status = KeyStatus.DOWN | |
elif e.keycode == KEYCODE_START and self._game_state == MainStatus.Main: | |
self._game_start() | |
elif e.keycode == KEYCODE_PAUSE: | |
if self._game_state == MainStatus.Game: | |
self._game_pause() | |
elif self._game_state == MainStatus.Pause: | |
self._game_resume() | |
elif self._game_state == MainStatus.Win: | |
self._game_main() | |
def _win_key_release(self, e: tk.Event): | |
if e.keycode == KEYCODE_P1_UP and self._p1_status == KeyStatus.UP: | |
self._p1_status = KeyStatus.NORMAL | |
elif e.keycode == KEYCODE_P1_DOWN and self._p1_status == KeyStatus.DOWN: | |
self._p1_status = KeyStatus.NORMAL | |
elif e.keycode == KEYCODE_P2_UP and self._p2_status == KeyStatus.UP: | |
self._p2_status = KeyStatus.NORMAL | |
elif e.keycode == KEYCODE_P2_DOWN and self._p2_status == KeyStatus.DOWN: | |
self._p2_status = KeyStatus.NORMAL | |
#################################################################################################### | |
def _game_main(self): | |
"""메인 함수를 띄우는 함수 | |
""" | |
self._game_state = MainStatus.Main | |
self._canvas.delete("all") | |
self._create_text(RENDER_MAIN_KEY) | |
self._create_text(RENDER_MAIN_TITLE) | |
self._create_text(RENDER_MAIN_NAME) | |
self._create_text(RENDER_MAIN_HOWTOSTART) | |
def _game_pause(self): | |
"""일시정지 화면을 띄우는 함수 | |
""" | |
self._game_state = MainStatus.Pause | |
self._time_paused_at = time.time() | |
self._create_text(RENDER_PAUSE, tag="pause") | |
def _game_resume(self): | |
"""일시정지 상태에서 복구시키는 함수 | |
""" | |
self._canvas.delete("pause") | |
# 일시정지한 시간만큼 게임 끝나는 시간 연장 | |
now = time.time() | |
paused_time = now - self._time_paused_at | |
self._time_rendered_at = now | |
self._ball_speed_base += paused_time | |
self._time_gameover_at += paused_time | |
self._game_state = MainStatus.Game | |
def _game_win(self): | |
"""승자와 점수를 표시하는 함수 | |
""" | |
self._game_state = MainStatus.Win | |
self._canvas.delete("all") | |
self._create_text( | |
RENDER_WIN_TITLE, | |
x=0 | |
if self._score_p1 == self._score_p2 | |
else (1 if self._score_p1 > self._score_p2 else 2), | |
) | |
self._create_text(RENDER_WIN_SCORE, x=(self._score_p1, self._score_p2)) | |
self._create_text(RENDER_WIN_RESTART) | |
def _game_start(self): | |
"""게임을 시작하기 위해 다양한 초기화를 진행하는 함수 | |
""" | |
self._canvas.delete("all") | |
now = time.time() | |
self._time_gameover_at = now + GAME_TIME | |
self._time_rendered_at = now | |
self._time_scoring_before = None | |
self._time_p1_glow = None | |
self._time_p2_glow = None | |
self._time_ball_bounce = None | |
self._score_p1 = 0 | |
self._score_p2 = 0 | |
# -------------------------------------------------------------------------------- | |
# 벽 | |
self._create_rectangle(WALL_TOP_BOUNDS, fill=WALL_COLOR, width=0) | |
self._create_rectangle(WALL_BOTTOM_BOUNDS, fill=WALL_COLOR, width=0) | |
self._create_rectangle(WALL_LEFT_TOP_BOUNDS, fill=WALL_COLOR, width=0) | |
self._create_rectangle(WALL_LEFT_BOTTOM_BOUNDS, fill=WALL_COLOR, width=0) | |
self._create_rectangle(WALL_RIGHT_TOP_BOUNDS, fill=WALL_COLOR, width=0) | |
self._create_rectangle(WALL_RIGHT_BOTTOM_BOUNDS, fill=WALL_COLOR, width=0) | |
for bounds in WALL_ADDITIONAL_LIST: | |
self._create_rectangle(bounds, fill=WALL_COLOR, width=0) | |
# 골인 지점 | |
self._create_rectangle( | |
GOAL_BOUNDS_P1, fill=GOAL_COLOR, width=0, | |
) | |
self._create_rectangle( | |
GOAL_BOUNDS_P2, fill=GOAL_COLOR, width=0, | |
) | |
# -------------------------------------------------------------------------------- | |
# 남은 시간과 양 플레이어의 점수 표시 | |
self._create_text(RENDER_GAME_SEC_DESC) | |
self._id_remain_seconds = self._create_text(RENDER_GAME_SEC_VALUE, x=GAME_TIME) | |
self._create_text(RENDER_GAME_SCORE_DESC_P1) | |
self._create_text(RENDER_GAME_SCORE_DESC_P2) | |
self._id_score_p1 = self._create_text(RENDER_GAME_SCORE_VALUE_P1, x=0) | |
self._id_score_p2 = self._create_text(RENDER_GAME_SCORE_VALUE_P2, x=0) | |
# -------------------------------------------------------------------------------- | |
# 양측 플레이어 막대기 | |
self._p1_bar_y = BAR_Y_INIT | |
self._p2_bar_y = BAR_Y_INIT | |
self._p1_bar_bounds = to_bounds((BAR_P1_X, self._p1_bar_y), BAR_SIZE) | |
self._p2_bar_bounds = to_bounds((BAR_P2_X, self._p2_bar_y), BAR_SIZE) | |
self._id_p1_bar = self._create_rectangle( | |
self._p1_bar_bounds, fill=BAR_P1_COLOR[0], width=0, | |
) | |
self._id_p2_bar = self._canvas.create_rectangle( | |
self._p2_bar_bounds, fill=BAR_P2_COLOR[0], width=0, | |
) | |
self._p1_status: KeyStatus = KeyStatus.NORMAL | |
self._p2_status: KeyStatus = KeyStatus.NORMAL | |
# -------------------------------------------------------------------------------- | |
# 공 초기화 및 그리기 | |
self._reset_ball() | |
self._id_ball = self._create_oval( | |
self._ball_center, BALL_RADIUS, fill=BALL_COLOR, width=0, | |
) | |
self._game_state = MainStatus.Game | |
#################################################################################################### | |
def _reset_ball(self): | |
"""공 위치를 초기화 하는 함수 | |
""" | |
now = time.time() | |
self._ball_center: Point = BALL_POS_INIT | |
self._ball_speed_base = BALL_SPEED_INIT | |
self._ball_speed = self._ball_speed_base | |
self._time_ball_move_after = now + BALL_WAIT_TIME | |
self._time_gameover_at += BALL_WAIT_TIME | |
d = random.randint(0, 3) | |
a = random.random() * BALL_ANGLE_INIT_RANGE | |
"""좌표평면에서는 x+ 시작이지만, 현재 코드에서는 y-축에서 시작""" | |
if d == 0: # ↗ | |
self._ball_angle = PI3 + a | |
elif d == 1: # ↖ | |
self._ball_angle = PI1 - a | |
elif d == 2: # ↙ | |
self._ball_angle = PI1 + a | |
elif d == 3: # ↘ | |
self._ball_angle = PI3 - a | |
print( | |
"init angle", | |
self._ball_angle, | |
math.degrees(self._ball_angle), | |
d, | |
math.degrees(a), | |
) | |
# 벽 종류 | |
class _WallType(enum.Enum): | |
P1 = 0 # 플레이어 막대기 | |
P2 = 1 # 플레이어 막대기 | |
P1_GOAL = 2 # 플레이어 점수 | |
P2_GOAL = 3 # 플레이어 점수 | |
WALL = 4 # 벽 | |
# 이동 방향 | |
class _MoveDirection(enum.IntEnum): | |
UP = 0 | |
LEFT = 1 | |
RIGHT = 2 | |
DOWN = 3 | |
# 공의 충돌 정보 | |
class _HitData(typing.NamedTuple): | |
point: Point # 충돌 지점 | |
distance: float # 충돌 지점까지의 거리 | |
direction: int # 공의 이동 방향 | |
angle: float # 부딛힌 면의 각도 | |
def _move_ball(self, distance: float): | |
"""공을 이동시킨다. | |
Args: | |
distance : 이동할 거리. | |
""" | |
now = time.time() | |
"""충돌 감지할 물체 목록 | |
[0] 바운딩 정보 | |
[1] Margin 보정을 할 것인지 여부 | |
[2] 물체 타입 | |
""" | |
WALL_LIST: typing.List[typing.Tuple[Bounds, bool, Game._WallType]] = [ | |
(self._p1_bar_bounds, True, Game._WallType.P1), | |
(self._p2_bar_bounds, True, Game._WallType.P2), | |
(GOAL_BOUNDS_P1, False, Game._WallType.P1_GOAL), | |
(GOAL_BOUNDS_P2, False, Game._WallType.P2_GOAL), | |
(WALL_TOP_BOUNDS, True, Game._WallType.WALL), | |
(WALL_LEFT_TOP_BOUNDS, True, Game._WallType.WALL), | |
(WALL_LEFT_BOTTOM_BOUNDS, True, Game._WallType.WALL), | |
(WALL_RIGHT_TOP_BOUNDS, True, Game._WallType.WALL), | |
(WALL_RIGHT_BOTTOM_BOUNDS, True, Game._WallType.WALL), | |
(WALL_BOTTOM_BOUNDS, True, Game._WallType.WALL), | |
] + [(x, True, Game._WallType.WALL) for x in WALL_ADDITIONAL_LIST] | |
# ================================================================================ | |
looped = 0 | |
while BALL_MOVE_MIN < distance: | |
# 이동거리 계산 | |
delta = Point( | |
distance * math.sin(self._ball_angle), | |
-distance * math.cos(self._ball_angle), | |
) | |
# 이동하는 횟수를 최대 N 회 까지만 계산한다. 넘어가면 강제 이동처리 | |
looped += 1 | |
if 20 < looped: | |
self._ball_center = self._ball_center + delta | |
break | |
# -------------------------------------------------------------------------------- | |
# 공의 이동 경로가 각 물체에 부딛힌 위치를 계산. 부딛힌 자료를 저장한다. | |
hit_data_list: typing.List[typing.Tuple[Game._WallType, Game._HitData]] = [] | |
for (bounds, with_margin, tag) in WALL_LIST: | |
hit_data = Game.___ball_hits_rect( | |
self._ball_center, delta, bounds, with_margin | |
) | |
if hit_data: | |
hit_data_list.append((tag, hit_data)) | |
# -------------------------------------------------------------------------------- | |
# 부딛히는 항목이 없다면 공을 이동시킴 | |
if len(hit_data_list) == 0: | |
self._ball_center = self._ball_center + delta | |
break | |
hit_wall_type, hit_data = min(hit_data_list, key=lambda x: x[1]) | |
# -------------------------------------------------------------------------------- | |
# 점수내기의 경우 공 좌표가 리셋이된다... | |
if hit_wall_type in [Game._WallType.P1_GOAL, Game._WallType.P2_GOAL]: | |
if hit_wall_type == Game._WallType.P1_GOAL: | |
self._score_p1 += 1 | |
else: | |
self._score_p2 += 1 | |
self._ball_center = hit_data.point | |
self._time_scoring_before = now + SLEEP_WHEN_SCORE | |
self._time_gameover_at += SLEEP_WHEN_SCORE | |
break | |
# -------------------------------------------------------------------------------- | |
""" | |
공통처리. | |
공 좌표를 충돌지점으로 이동, | |
기본 속도 증가 | |
바운스 색상으로 변경... | |
남은 이동거리 계산, | |
""" | |
self._ball_center = hit_data.point | |
self._ball_speed_base += BALL_SPEED_ADDITIONAL | |
self._ball_speed = self._ball_speed_base | |
self._time_ball_bounce = now + BALL_BOUNCE_COLOR_TIME | |
# if not trace_mode: | |
distance = max(distance - hit_data.distance, 0) | |
""" | |
p1일때 왼 쪽으로 와서 오른쪽에 튕기거나 | |
p2일때 오른쪽으로 와서 왼 쪽에 튕기거나 했을 땐 | |
각도랑 속도를 조절한다. | |
""" | |
if ( | |
hit_wall_type == Game._WallType.P1 | |
and hit_data.direction == Game._MoveDirection.LEFT | |
) or ( | |
hit_wall_type == Game._WallType.P2 | |
and hit_data.direction == Game._MoveDirection.RIGHT | |
): | |
bar_y = ( | |
self._p1_bar_y | |
if hit_wall_type == Game._WallType.P1 | |
else self._p2_bar_y | |
) | |
k = (self._ball_center.y - bar_y) / BAR_SIZE.h | |
# 외각에서 맞으면 더 빠르게, 더 큰 각도로 이동. | |
if hit_wall_type == Game._WallType.P1: | |
self._ball_angle = (PI1 + BAR_BALL_REBOUND_ANGLE * k) % PI4 | |
self._time_p1_glow = now + BAR_GLOW_TIME | |
else: | |
self._ball_angle = (PI3 - BAR_BALL_REBOUND_ANGLE * k) % PI4 | |
self._time_p2_glow = now + BAR_GLOW_TIME | |
self._ball_speed = self._ball_speed_base * ( | |
1 + BAR_BALL_REBOUND_ACCELERATION * k | |
) | |
else: | |
# 입사각 및 반사각 수정. | |
self._ball_angle = (hit_data.angle - self._ball_angle) % PI4 | |
# 너무 직각으로 튀지 않도록 각도 추가 수정 | |
if PI1 - BALL_ANGLE_VERT_LIMIT < self._ball_angle <= PI1: # 1사분면 | |
self._ball_angle = PI1 - BALL_ANGLE_VERT_LIMIT | |
elif PI1 <= self._ball_angle <= PI1 + BALL_ANGLE_VERT_LIMIT: # 2사분면 | |
self._ball_angle = PI1 + BALL_ANGLE_VERT_LIMIT | |
elif PI3 - BALL_ANGLE_VERT_LIMIT < self._ball_angle <= PI3: # 3사분면 | |
self._ball_angle = PI3 - BALL_ANGLE_VERT_LIMIT | |
elif PI3 <= self._ball_angle < PI3 + BALL_ANGLE_VERT_LIMIT: # 4사분면 | |
self._ball_angle = PI3 + BALL_ANGLE_VERT_LIMIT | |
# ================================================================================ | |
# 공이 화면 밖으로 나간 경우 강제 재시작 | |
if not ( | |
0 <= self._ball_center.x <= WINDOW_SIZE.w | |
and 0 <= self._ball_center.y <= WINDOW_SIZE.h | |
): | |
self._reset_ball() | |
def ___ball_hits_rect( | |
center: Point, delta: Point, bounds: Bounds, with_margin: bool | |
) -> typing.Optional[_HitData]: | |
"""공이 이동 궤적이 해당 사각형 물체에 충돌할 수 있는지 판별하는 함수. | |
Args: | |
center : 공의 현재 중심 | |
delta : 공이 이동할 좌표 변화값 | |
bounds : 충돌을 감지할 물체 | |
with_margin : 양 끝점 보정을 할 것인지 | |
Results: | |
None : 출돌 위치가 없으면 반환. | |
_HitData : 충돌 위치와 거리, 충돌 위치를 반환. | |
""" | |
point: typing.Optional[Point] = None | |
dir: typing.Optional[Game._MoveDirection] = None # 충돌 위치 | |
move_to = center + delta | |
margin = BALL_RADIUS if with_margin else 0 | |
angle = PI2 | |
# 양 옆 충돌범위 먼저 판별 | |
if delta.x < 0: # 왼쪽으로 이동. | |
angle = PI0 | |
dir = Game._MoveDirection.LEFT | |
point = Game.___get_intersect_point( | |
center, | |
move_to, | |
Point(bounds.right + BALL_RADIUS, bounds.top - margin), | |
Point(bounds.right + BALL_RADIUS, bounds.bottom + margin), | |
) | |
elif delta.x > 0: # 오른쪽으로 이동 | |
angle = PI0 | |
dir = Game._MoveDirection.RIGHT | |
point = Game.___get_intersect_point( | |
center, | |
move_to, | |
Point(bounds.left - BALL_RADIUS, bounds.top - margin), | |
Point(bounds.left - BALL_RADIUS, bounds.bottom + margin), | |
) | |
# 양 옆으로 충돌이 없었으면 옆으로 | |
if point is None: | |
if delta.y < 0: # 위쪽으로 이동. | |
angle = PI2 | |
dir = Game._MoveDirection.UP | |
point = Game.___get_intersect_point( | |
center, | |
move_to, | |
Point(bounds.left - margin, bounds.bottom + BALL_RADIUS), | |
Point(bounds.right + margin, bounds.bottom + BALL_RADIUS), | |
) | |
elif delta.y > 0: # 아래쪽으로 이동 | |
angle = PI2 | |
dir = Game._MoveDirection.DOWN | |
point = Game.___get_intersect_point( | |
center, | |
move_to, | |
Point(bounds.left - margin, bounds.top - BALL_RADIUS), | |
Point(bounds.right + margin, bounds.top - BALL_RADIUS), | |
) | |
# 충돌이 없었으면 반환. | |
if point is None: | |
return None | |
# 충돌이 있었으면 충돌 지점까지의 거리 계산 | |
d = math.sqrt((center.x - point.x) ** 2 + (center.y - point.y) ** 2) | |
return Game._HitData(point, d, dir, angle) | |
def ___get_intersect_point( | |
p1: Point, p2: Point, p3: Point, p4: Point | |
) -> typing.Optional[Point]: | |
"""선분A(p1-p2) 와 선분B(p3-p4) 사이에 교점 있는지 확인하는 함수 | |
Args: | |
p1, p2 : 선분 A의 양 끝점 | |
p3, p4 : 선분 B의 양 끝점 | |
Results: | |
접점. 접점이 없으면 None 을 반한한다. | |
.. 참고: | |
점 A, B를 잇는 선분 위에 있는 한 점 P(k)를 정의한다. | |
P(k) = A * (1 - k) + B * k | |
단, k in [0, 1] | |
선분A 위의 점 Pa(t) | |
Pa(t) = p_1 * (1 - t) + p_2 * t | |
= p_1 + (p_2 - p_1) * t ......................................... f1 | |
단, t in [0, 1] | |
선분B 위의 점 Pb(s) | |
Pa(t) = p_1 * (1 - s) + p_2 * s | |
= p_1 + (p_2 - p_1) * s ......................................... f2 | |
단, s in [0, 1] | |
두 선분의 교차점 P는 Pa(t)와 Pb(s)는 동일 | |
P = p_1 + (p_2 - p_1) * t = p_3 + (p_4 - p_3) * s ..................... f3 | |
점 P의 x, y 를 각각 계산하면 | |
P_x = x_1 + (x_2 - x_1) * t = x_3 + (x_4 - x_3) * s ................... f4 | |
P_y = y_1 + (y_2 - y_1) * t = y_3 + (y_4 - y_3) * s ................... f5 | |
f4, f5를 t, s에 대해서 계산하면 | |
k = (x_2 - x_1) * (y_4 - y_3) - (x_4 - x_3) * (y_2 - y_1) ............. f6 | |
t = ((x_4 - x_3) * (y_1 - y_3) - (x_1 - x_3) * (y_4 - y_3)) / k ....... f7 | |
s = ((x_2 - x_1) * (y_1 - y_3) - (x_1 - x_3) * (y_2 - y_1)) / k ....... f8 | |
단, t in [0, 1] | |
s in [0, 1] | |
f6, f7, f8 에 의해 | |
x = x_1 + t * (x_2 - x_1) ............................................. f9 | |
y = y_1 + t * (y_2 - y_1) ............................................. f10 | |
""" | |
k = (p2.x - p1.x) * (p4.y - p3.y) - (p4.x - p3.x) * (p2.y - p1.y) | |
if k == 0: | |
return None | |
t = ((p4.x - p3.x) * (p1.y - p3.y) - (p1.x - p3.x) * (p4.y - p3.y)) / k | |
if t < 0 or 1 < t: | |
return None | |
s = ((p2.x - p1.x) * (p1.y - p3.y) - (p1.x - p3.x) * (p2.y - p1.y)) / k | |
if s < 0 or 1 < s: | |
return None | |
return Point(p1.x + t * (p2.x - p1.x), p1.y + t * (p2.y - p1.y),) | |
#################################################################################################### | |
def _animation(self): | |
"""아이템의 이동 등을 처리하는 함수 | |
""" | |
while True: | |
time.sleep(1.0 / FPS) | |
if self._game_state != MainStatus.Game: | |
continue | |
# -------------------------------------------------------------------------------- | |
now = time.time() | |
if self._time_gameover_at <= now: | |
self._game_win() | |
continue | |
dt = now - self._time_rendered_at | |
self._time_rendered_at = now | |
# -------------------------------------------------------------------------------- | |
# 사용자가 버튼을 누르고 있는지 확인 | |
if self._p1_status == KeyStatus.UP or self._p1_status == KeyStatus.DOWN: | |
if self._p1_status == KeyStatus.UP: | |
self._p1_bar_y = max( | |
self._p1_bar_y - BAR_SPEED_PER_SEC * dt, BAR_Y_RANGE[0] | |
) | |
else: | |
self._p1_bar_y = min( | |
self._p1_bar_y + BAR_SPEED_PER_SEC * dt, BAR_Y_RANGE[1] | |
) | |
self._p1_bar_bounds = to_bounds((BAR_P1_X, self._p1_bar_y), BAR_SIZE) | |
self._coords(self._id_p1_bar, self._p1_bar_bounds) | |
if self._p2_status == KeyStatus.UP or self._p2_status == KeyStatus.DOWN: | |
if self._p2_status == KeyStatus.UP: | |
self._p2_bar_y = max( | |
self._p2_bar_y - BAR_SPEED_PER_SEC * dt, BAR_Y_RANGE[0] | |
) | |
else: | |
self._p2_bar_y = min( | |
self._p2_bar_y + BAR_SPEED_PER_SEC * dt, BAR_Y_RANGE[1] | |
) | |
self._p2_bar_bounds = to_bounds((BAR_P2_X, self._p2_bar_y), BAR_SIZE) | |
self._coords(self._id_p2_bar, self._p2_bar_bounds) | |
# -------------------------------------------------------------------------------- | |
# 바 부딛힘 색변화 처리 | |
if self._time_p1_glow is not None: | |
if now < self._time_p1_glow: | |
self._canvas.itemconfigure(self._id_p1_bar, fill=BAR_P1_COLOR[1]) | |
else: | |
self._time_p1_glow = None | |
self._canvas.itemconfigure(self._id_p1_bar, fill=BAR_P1_COLOR[0]) | |
if self._time_p2_glow is not None: | |
if now < self._time_p2_glow: | |
self._canvas.itemconfigure(self._id_p2_bar, fill=BAR_P2_COLOR[1]) | |
else: | |
self._time_p2_glow = None | |
self._canvas.itemconfigure(self._id_p2_bar, fill=BAR_P2_COLOR[0]) | |
# -------------------------------------------------------------------------------- | |
if self._time_scoring_before is None: | |
# 공의 이동 연산을 처리하는 부분 | |
if self._time_ball_move_after < now: | |
self._move_ball(self._ball_speed * dt) | |
if self._canvas_id_ball_count is not None: | |
self._canvas.delete(self._canvas_id_ball_count) | |
self._canvas_id_ball_count = None | |
if self._time_ball_bounce is not None: | |
if now < self._time_ball_bounce: | |
self._canvas.itemconfigure(self._id_ball, fill=BALL_COLOR_BOUNCE) | |
else: | |
self._time_ball_bounce = None | |
self._canvas.itemconfigure(self._id_ball, fill=BALL_COLOR) | |
# 점수 발생 시 공을 깜빡깜빡하게 만드는 부분 | |
if self._time_scoring_before is not None: | |
if now < self._time_scoring_before: | |
dt = int((self._time_scoring_before - now) * 4) % 2 | |
self._canvas.itemconfigure( | |
self._id_ball, fill=BALL_COLOR_HIGHLIGHT[dt] | |
) | |
else: | |
self._time_scoring_before = None | |
self._canvas.itemconfigure(self._id_ball, fill=BALL_COLOR) | |
self._reset_ball() | |
self._coords( | |
self._id_ball, to_bounds(self._ball_center, BALL_SIZE), | |
) | |
# 공 이동까지 몇 초 남았나 보여주는거 | |
if now < self._time_ball_move_after: | |
dt = self._time_ball_move_after - now | |
if self._canvas_id_ball_count is None: | |
self._canvas_id_ball_count = self._create_text( | |
RENDER_BALL_MOVE_COUNT, x=math.ceil(dt) | |
) | |
else: | |
self._canvas.itemconfigure( | |
self._canvas_id_ball_count, text=f"{int(math.ceil(dt))}" | |
) | |
# 점수 갱신 | |
self._canvas.itemconfigure(self._id_score_p1, text=str(self._score_p1)) | |
self._canvas.itemconfigure(self._id_score_p2, text=str(self._score_p2)) | |
# -------------------------------------------------------------------------------- | |
"""남은 시간을 멈춰야 하는 경우 | |
1. 점수 땄을 때 | |
2. 공이 이동 대기중일 때 | |
""" | |
remain_seconds_f = self._time_gameover_at - now | |
remain_seconds = int(math.ceil(remain_seconds_f)) | |
if ( | |
self._time_scoring_before is None or self._time_scoring_before < now | |
) and ( | |
self._time_ball_move_after is None or self._time_ball_move_after < now | |
): | |
self._canvas.itemconfigure( | |
self._id_remain_seconds, text=str(remain_seconds) | |
) | |
if remain_seconds <= 10: | |
# 10초 이하 남았으면 깜빡거림 | |
self._canvas.itemconfigure( | |
self._id_remain_seconds, | |
fill=RENDER_GAME_SEC_VALUE.color(remain_seconds_f), | |
) | |
# 점수 갱신 | |
self._canvas.itemconfigure(self._id_score_p1, text=str(self._score_p1)) | |
self._canvas.itemconfigure(self._id_score_p2, text=str(self._score_p2)) | |
#################################################################################################### | |
# 유틸리티 함수들 | |
def _coords(self, id, bounds: Bounds): | |
"""canvas.corrds 를 수행하는 함수 | |
Args: | |
id : canvas 오브젝트 ID | |
bounds : 객체 바운딩 | |
""" | |
self._canvas.coords(id, bounds.left, bounds.top, bounds.right, bounds.bottom) | |
def _create_oval(self, center: Point, radius: float, **kwargs) -> int: | |
"""create_oval 를 수행하는 함수 | |
Args: | |
center: 원의 중심점 | |
radius: 원의 반지름 | |
kwargs | |
Returns: | |
int: canvas 오브젝트 ID | |
""" | |
return self._canvas.create_oval( | |
center.x - radius, | |
center.y - radius, | |
center.x + radius, | |
center.y + radius, | |
kwargs, | |
) | |
def _create_rectangle(self, bounds: Bounds, **kwargs) -> int: | |
"""create_rectangle 를 수행하는 함수 | |
Args: | |
bounds: 객체 바운딩 | |
kwargs | |
Returns: | |
int: canvas 오브젝트 ID | |
""" | |
return self._canvas.create_rectangle( | |
bounds.left, bounds.top, bounds.right, bounds.bottom, kwargs | |
) | |
def _create_text( | |
self, text: Text, x: typing.Optional[typing.Any] = None, **kwargs, | |
) -> int: | |
"""canvas.create_text 를 수행하고 추가적인 정렬을 하는 함수 | |
Args: | |
text: 만들 텍스트 데이터 | |
tag: 객체 생성할 떄 쓸 태그 | |
x: args | |
Results: | |
int: canvas 오브젝트 ID | |
""" | |
args = kwargs if kwargs else {} | |
if isinstance(text.text, str): | |
args["text"] = text.text | |
elif x is not None: | |
args["text"] = text.text(x) | |
if text.color: | |
args["fill"] = text.color if isinstance(text.color, str) else text.color(x) | |
if text.font: | |
args["font"] = text.font | |
if text.anchor: | |
args["anchor"] = text.anchor | |
id = self._canvas.create_text(text.point.x, text.point.y, args) | |
return id | |
if __name__ == "__main__": | |
oop = Game() | |
oop.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment