Python による簡単なドライブゲーム
本プログラムは,Python と pygame による擬似 3 次元・一人称視点のドライブゲームである.プレイヤーはキーボードで車両を操作し,手続き的に無限生成される道路,分岐および交差点を走行する.ゲームオーバーやスコアは設けず,走行距離(distance,累積走行距離)が画面に表示される.
本資料は,pygame による描画ループ,dataclass と collections.deque による状態管理,math と random による手続き的生成,wave と array による波形データ生成,ヨー回転(車体の旋回回転),操舵角の蓄積,近接クリッピング(手前側の描画範囲制限)といったゲームプログラミング要素の習得を目的とする.
実行には pygame,numpy および pillow のインストールが必要である.動作環境は Windows,画面解像度 1280×720,Python 3.10 以降を想定する(本資料のインストール手順では Python 3.12.10 を推奨する).操作は,↑(加速),↓(減速・制動.停止後は後退),← / →(操舵.押し続けた時間に応じて舵角が増える蓄積式)である.
【目次】
- Python 基礎
- Python 3.12 のインストール
- Python の開発環境 Visual Studio Code のインストールと Python 用の設定
- Windows での pygame のインストールと動作確認
- Python プログラム実行手順
- Python プログラムの構造
- プログラムの実行時の留意事項
- Python プログラム
【サイト内の関連ページ】
Python 基礎
本プログラムで用いる Python の言語要素および標準ライブラリ・外部ライブラリと,プログラム内での用途を以下に示す.
| 要素 | プログラム内での用途 |
|---|---|
| 変数 | car_x,car_y,car_angle,speed,steer_angle,distance による車両状態の保持 |
| 式 | self.speed += ACCEL * dt,self.car_angle + yaw_rate * dt 等による速度・姿勢の時間積分 |
| if 文 | キー入力判定,路外判定,操舵入力閾値による進路支援の発動制御 |
| while 文 | メインゲームループ(while self.running)および道路生成の継続条件判定 |
| def 文 / class 文 | World,Road,Segment,EngineAudio,Game のクラスおよびメソッド定義 |
| dataclass | Segment,Road のデータ構造定義 |
| collections.deque | 道路ネットワーク探索における幅優先探索のキュー |
| typing.Optional | 関数および属性の型ヒント表記 |
| math / random | 三角関数による座標変換および回転,シード固定の擬似乱数による道路と区画の決定的生成 |
| wave / array | エンジン音波形のメモリ上生成および pygame.mixer.Sound への受け渡し |
| pygame | ウィンドウ生成,描画,キー入力,時間管理,音声 |
Python 3.12 のインストール
本章では、Pythonのインストールを行い、Pythonのプログラムを実行する環境を整える。扱う環境は、Windows搭載パソコンである。金子研究室では、Python 3.12.10を推奨する。
[Windows での Python 3.12 のインストール手順を見るには、ここをクリック]
Windows での Python 3.12 のインストール
以下のいずれかの方法でPython 3.12をインストールする。Pythonがインストール済みの場合、この手順は不要である。
方法 1:winget によるインストール
【インストールコマンドの実行方法】
管理者権限でコマンドプロンプトを起動する(手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。
--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動するとPATHが反映される。
REM Python 3.12 をシステム領域にインストール
winget install --id Python.Python.3.12 -e --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 Include_launcher=1 InstallLauncherAllUsers=1 TargetDir=\"C:\Program Files\Python312\""
REM Python と Scripts を PATH 先頭に追加
powershell -NoProfile -Command "$p='C:\Program Files\Python312'; $s=\"$p\Scripts\"; $c=[Environment]::GetEnvironmentVariable('Path','Machine'); if((Test-Path $p) -and (';'+$c+';' -notlike \"*;$p;*\") -and (';'+$c+';' -notlike \"*;$s;*\")){[Environment]::SetEnvironmentVariable('Path',\"$p;$s;$c\",'Machine')}"
方法 2:インストーラーによるインストール
- Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
- ダウンロードしたインストーラーを実行する。
- 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから
pythonコマンドを実行できない。 - 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。
インストールの確認
コマンドプロンプトで以下を実行する。
python --version
バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。
以降の章では、必要に応じて題材に応じたソフトウェアを追加する。
Python の開発環境 Visual Studio Code のインストールと Python 用の設定
本章では、Python の開発環境Visual Studio Code(プログラムを編集するソフトウェア。以下、VS Code)を整える。
[Windows での Visual Studio Code のインストールと Python 用の設定手順を見るには、ここをクリック]
Windows での Visual Studio Code のインストールと Python 用の設定手順
1. VS Code と拡張機能のインストール
以下のコマンドにより,既存の VS Code を削除し,全ユーザー共有の設定で再インストールしたうえで,拡張機能(VS Code に機能を追加するソフトウェア)をまとめて導入する.
【インストールコマンドの実行方法】
管理者権限でコマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして,コマンド全体をコマンドプロンプトにコピー&ペーストする。
インストールコマンド
REM ============================================================
REM Microsoft Visual Studio Code
REM ============================================================
winget uninstall -e --id Microsoft.VisualStudioCode --silent --disable-interactivity --accept-source-agreements
rmdir /s /q C:\ProgramData\vscode-extensions 2>nul
rmdir /s /q "%APPDATA%\Code" 2>nul
rmdir /s /q "%USERPROFILE%\.vscode" 2>nul
rmdir /s /q "%LOCALAPPDATA%\Microsoft\vscode-update" 2>nul
REM VS Code をシステム領域に新規インストール
winget install --scope machine --id Microsoft.VisualStudioCode -e --silent --accept-source-agreements --accept-package-agreements
REM 全ユーザー共有の拡張機能フォルダ
mkdir C:\ProgramData\vscode-extensions 2>nul
icacls "C:\ProgramData\vscode-extensions" /grant "Everyone:(OI)(CI)M" /T
REM スタートメニューのショートカットを --extensions-dir 付きで再作成
rmdir /s /q "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code" 2>nul
del "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk" 2>nul
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); $lnk.TargetPath='C:\Program Files\Microsoft VS Code\Code.exe'; $lnk.Arguments='--extensions-dir \"C:\ProgramData\vscode-extensions\"'; $lnk.Save()"
REM ショートカットの検証
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); Write-Host 'TargetPath:' $lnk.TargetPath; Write-Host 'Arguments:' $lnk.Arguments"
REM ファイル / フォルダ右クリックの「Code で開く」を登録
reg add "HKLM\SOFTWARE\Classes\*\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\Background\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%V\"" /f
REM --extensions-dir 付きで起動する code.cmd ラッパを作成
REM (%* を echo で書くと対話的 cmd で失われるため、PowerShell で [char]37+'*' を書き出す)
powershell -NoProfile -Command "$pct=[char]37; $q=[char]34; $c='@echo off'+[char]13+[char]10+$q+'C:\Program Files\Microsoft VS Code\bin\code.cmd'+$q+' --extensions-dir '+$q+'C:\ProgramData\vscode-extensions'+$q+' '+$pct+'*'+[char]13+[char]10; [IO.File]::WriteAllText('C:\ProgramData\vscode-extensions\vscode.cmd',$c,[Text.Encoding]::ASCII)"
REM 拡張機能のインストール
set "CODE=C:\Program Files\Microsoft VS Code\bin\code.cmd"
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot-chat
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.python
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.vscode-pylance
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.debugpy
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension MS-CEINTL.vscode-language-pack-ja
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension saoudrizwan.claude-dev
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension rust-lang.rust-analyzer
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension tamasfe.even-better-toml
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension anthropic.claude-code
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension almenon.arepl
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --list-extensions --show-versions
echo === セットアップ完了 ===
2. Python インタプリタの選択
同一マシンに複数の Python がインストールされている場合,VS Code で使用する Python 本体(インタプリタ:Python プログラムを解釈・実行するソフトウェア)を選択する必要がある.
- コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(
Ctrl+Shift+P) Python: Select Interpreterと入力する
- 表示される一覧から,使用する Python(例:
C:\Program Files\Python312\python.exe)を選択する.
以降の章では、必要に応じて題材に応じた必要なソフトウェアを追加する。
Windows での pygame のインストールと動作確認
管理者権限でコマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」).そして,コマンド全体をコマンドプロンプトにコピー&ペーストする.
以下のコマンドを実行し,pygame および関連ライブラリをインストールしたうえで,動作確認を行う.
REM pygame 関連ライブラリのインストール
pip install -U --no-user pygame numpy pillow
python -c "import pygame; print('pygame:', pygame.version.ver)"
REM
REM pygame サブモジュール動作確認
python -c "import pygame; pygame.init(); print('display:', pygame.display.get_init(), 'font:', pygame.font.get_init(), 'mixer:', pygame.mixer.get_init() is not None); pygame.quit()"
REM
REM pygame ウィンドウ表示動作確認(3 秒間ウィンドウを表示して自動終了)
python -c "import pygame; pygame.init(); s = pygame.display.set_mode((400, 300)); pygame.display.set_caption('pygame test'); s.fill((0, 128, 255)); pygame.display.flip(); pygame.time.wait(3000); pygame.quit(); print('display test OK')"
Python プログラム実行手順
[Windows での Python プログラム実行手順を見るには、ここをクリック]
Windows での Python 実行手順(Visual Studio Codeを使用)
プログラムファイルの作成と保存
- 左サイドバーの「エクスプローラー」アイコン(
Ctrl+Shift+E)をクリックする
- 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する
続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する
- フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
- ファイル名(例:
aitask.py.ファイル名は何でも良い)を入力しEnterを押す.拡張子は.py(Python ファイルを示す拡張子)とする
- 実行したいコードを選択し,
Ctrl+Cでコピーする.VS Code のエディタ領域にCtrl+Vで貼り付ける Ctrl+Sで保存する
プログラムの実行
- エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
- VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(
print関数の出力等)が表示される
- tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
- VS Code 下部のターミナルで実行結果を確認する.OpenCV ウィンドウ(OpenCV が画像を表示するために開く専用ウィンドウ)が開いた場合はそちらも確認する.OpenCV ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する
Python プログラムの構造
システム構成
プログラムは Python と pygame を用い,単一ファイルで構成する.外部アセット(画像・音声ファイル等)への依存を避け,コード内生成で完結させる.実行は python ファイル名.py により行う.
表示方式
画面表示は一人称視点とする.俯瞰視点,トップダウン表示,ミニマップ,外部カメラ,追従カメラ切替は実装しない.画面下部には車両のボンネットを表示する.
ゲーム進行
ゲームは無限走行型とする.道路に距離上限を設けず,車両前方および周辺に必要な分だけ道路セグメントを逐次生成し,不要になった遠方データは破棄する.画面左上に速度,走行距離,方位および操舵角を表示する.
入力操作
- ↑(上キー):加速
- ↓(下キー):減速・制動(停止後は後退)
- ←(左キー):左操舵
- →(右キー):右操舵
左右操作は車体向きを直接変える方式ではなく,操舵角を内部状態として保持する蓄積式とする.キー押下継続時間に応じて操舵角が増加し,キー解放時には中立位置(操舵角 0)へ復帰する.
車両挙動
- 操舵角は最大舵角で制限する
- 車体のヨー回転(車体の旋回回転)は速度と操舵角に応じて変化する
- 後退時には前進時と異なる操舵応答とする
- 路外走行時には速度低下を発生させる
最高速度
高速域でも動作破綻が起きないよう,道路生成距離,描画距離,操舵応答および音声変化を整合させる.
道路生成方式
道路は短い直線セグメントの連結によって構成する.曲線道路は,ヘディング(進行方向角)を少しずつ変化させた直線セグメントの連結により表現し,折れ角を避ける.道路生成はインクリメンタル方式(必要時に都度追加する方式)とし,車両の近傍および前方に限定して実施する.
道路形状
- 短めの直線区間
- 緩いカーブ区間
- 中程度のカーブ区間
- 鋭いカーブ区間
分岐仕様
道路には片側分岐(枝道)を含める.分岐はプレイヤーの位置および向きに応じて進入可能とし,事前予約制や強制進路選択は行わない.分岐先道路に進入した後も,一定間隔で次の分岐または交差点が再出現する.
交差点仕様
道路には二方向交差点を含める.交差点では直進,左折,右折が可能である.進行先は車両の現在位置,向きおよび入力に基づいて決まり,レール状の進路固定は行わない.
分岐および交差点の出現制御
- 各道路ごとに次回イベント候補位置を内部管理する
- 候補位置到達時に分岐または交差点の生成を試みる
- 生成失敗時には短い間隔で再試行する
- 本線だけでなく枝道(片側分岐)側でも同一規則を適用する
- 一定深さまでは再帰的にイベント生成を許可する
進路支援
運転支援として,道路中央への横方向復元補助および道路接線方向への向き補助を設ける.補助は強制的なロックではなく,プレイヤーが道路外へ逸脱したり逆走したりできる自由度を残す.補助は,操舵入力が 6 度未満のとき,かつ車両が道路中心から ROAD_GUIDE_RANGE 以内にあるときに作用する.
道路視認支援
- 分岐および交差点の手前に予告標識を表示する
- 標識は片側分岐では該当側のみ,交差点では左右両側に表示する
- 標識は複数段階の予告距離で表示する
- 分岐先道路および交差方向を視認可能とする
描画仕様
描画は擬似 3 次元の遠近投影方式とする.道路,分岐道路,交差道路,住宅,駐車場,樹木および街灯を一人称視点で投影描画する.near plane(NEAR_PLANE.カメラに最も近い描画開始面.近接クリッピングの基準面)以遠を描画対象とし,道路ポリラインは線分単位で ROAD_CLIP_Z 平面と交差クリッピングすることで,画面手前の道路を連続して表示する.
空と遠景
- 空は地平線から上方向へのグラデーションとする
- 遠景には山並みを表示し,走行方向を把握するための視覚的手掛かりとする
- 太陽を固定方向に配置する
太陽と影
太陽は固定方向光源として扱い,住宅,樹木,街灯および駐車場に簡易的な影を落とす.影は厳密な物理計算を行わない簡易表現とする.
沿道地物
沿道区画は手続き的に生成する.区画配置は決定的であり,同一座標では同一内容が再現される.区画の過半数は空き地とし,建物は低層住宅のみとする.
土地利用比率
- 50 パーセント超を空き区画とする
- 駐車場は低頻度で配置する
- 路側オブジェクトは樹木または街灯とする
配置制約
- 道路に近すぎる位置には地物を置かない
- 相互に重なりやすい位置を避ける
道路描画要件
- 道路面をポリゴンとして描画する
- 左右端線を描画する
- 主道路には中央線を描画する
- 分岐道路および交差道路も前方視界内で描画する
音響仕様
エンジン音を実装する.音はコード内で合成し,速度に応じて段階的に音色またはピッチが変化する.基本周波数の異なる 10 種類のループ音を事前生成し,速度に応じて再生中チャネルを切り替える.pygame.mixer の初期化に失敗した環境では無音のままゲームループが継続する.
性能要件
- 無限走行を継続可能とする
- 道路データを必要範囲のみ保持する
- Windows の一般的な pygame 実行環境で動作する
保守要件
ワールド管理,道路生成,描画,音声およびゲームループの責務を分離した構造とする.
プログラムの実行時の留意事項
実行方法
- 前節のコマンドで pygame,numpy および pillow をインストールする
- 対象ファイルを保存する
python ファイル名.pyを実行する
Python プログラム
# -*- coding: utf-8 -*-
# 操作:
# ↑ 加速
# ↓ 減速・制動(停止後は後退)
# ← → 操舵
#
# 備考:
# ・操舵は押し続けた時間に応じて舵角が増える蓄積式である。
# ・分岐・交差点は、曲がった後の道路でも一定間隔で再出現する。
# ・音声は環境により無音でも動作継続する。
import math
import random
import io
import wave
from array import array
from dataclasses import dataclass, field
from collections import deque
from typing import Optional
import pygame
WIDTH = 1280
HEIGHT = 720
FPS = 60
SKY_TOP = (60, 120, 220)
SKY_HORIZON = (170, 210, 255)
GROUND_NEAR = (84, 120, 70)
GROUND_FAR = (118, 145, 92)
ROAD_COLOR = (80, 80, 86)
ROAD_EDGE = (220, 220, 220)
CENTER_LINE = (250, 235, 130)
PARKING_COLOR = (88, 88, 92)
HOUSE_WALL = [(222, 205, 188), (204, 196, 222), (218, 214, 186), (201, 219, 202)]
HOUSE_ROOF = [(138, 74, 62), (110, 96, 76), (88, 74, 108), (124, 80, 52)]
TREE_TRUNK = (110, 80, 40)
TREE_LEAF = [(55, 125, 58), (64, 144, 72), (90, 150, 84)]
LAMP_POLE = (115, 115, 122)
LAMP_HEAD = (240, 225, 160)
SUN_COLOR = (255, 245, 170)
MOUNTAIN_NEAR = (82, 98, 126)
MOUNTAIN_FAR = (132, 150, 176)
SHADOW = (0, 0, 0, 50)
ROAD_HALF_WIDTH = 8.5
LANE_LINE_HALF = 0.18
SEG_LEN = 16.0
GENERATE_AHEAD = 130
KEEP_BEHIND = 40
PATH_SAMPLE_AHEAD = 150
ROAD_GUIDE_RANGE = 35.0
OFFROAD_DRAG = 1.7
MAX_SPEED = 156.0
REVERSE_SPEED = -16.0
ACCEL = 28.0
BRAKE = 34.0
ROLL_DRAG = 3.2
MAX_STEER_ANGLE = math.radians(32.0)
STEER_BUILD_RATE = math.radians(82.0)
STEER_RETURN_RATE = math.radians(118.0)
TURN_RESPONSE = 0.85
CENTER_ASSIST = 0.08
HEADING_ASSIST = 0.020
SIGN_SIDE_OFFSET = ROAD_HALF_WIDTH + 5.0
SIGN_POLE_HEIGHT = 3.2
SIGN_FACE_SIZE = 1.55
NEAR_PLANE = 0.2
ROAD_CLIP_Z = 1.0
FOCAL = 760.0
HORIZON_Y = HEIGHT * 0.44
CAMERA_HEIGHT = 1.45
HOOD_Y = HEIGHT - 56
WORLD_SEED = 24117
SUN_DIR = math.radians(35.0)
SUN_ELEV = 0.36
EVENT_DEPTH_LIMIT = 4
ROOT_EVENT_MIN = 9
ROOT_EVENT_MAX = 15
BRANCH_EVENT_MIN = 11
BRANCH_EVENT_MAX = 18
RETRY_EVENT_MIN = 3
RETRY_EVENT_MAX = 6
def clamp(v, a, b):
return a if v < a else b if v > b else v
def lerp(a, b, t):
return a + (b - a) * t
def wrap_angle(a):
while a <= -math.pi:
a += math.tau
while a > math.pi:
a -= math.tau
return a
def angle_diff(a, b):
return wrap_angle(a - b)
def vec_from_angle(a):
return math.cos(a), math.sin(a)
def hash_mix(n):
n = (n ^ 0x9E3779B9) & 0xFFFFFFFF
n = (n * 0x85EBCA6B) & 0xFFFFFFFF
n ^= (n >> 13)
n = (n * 0xC2B2AE35) & 0xFFFFFFFF
n ^= (n >> 16)
return n & 0xFFFFFFFF
def stable_rng(*values):
h = WORLD_SEED & 0xFFFFFFFF
for v in values:
if isinstance(v, float):
v = int(v * 1000)
h = hash_mix(h ^ (int(v) & 0xFFFFFFFF))
return random.Random(h)
@dataclass
class Segment:
sid: int
road_id: int
index_in_road: int
x0: float
y0: float
x1: float
y1: float
heading: float
length: float
prev_sid: Optional[int]
next_sids: list = field(default_factory=list)
@dataclass
class Road:
road_id: int
start_sid: int
kind: str
root_heading: float
alive: bool = True
style_id: int = 0
current_heading: float = 0.0
curvature_step: float = 0.0
step_in_style: int = 0
style_remaining: int = 0
branch_cooldown: int = 16
parent_road: Optional[int] = None
event_depth: int = 0
next_event_index: int = 12
class World:
def __init__(self):
self.next_segment_id = 1
self.next_road_id = 1
self.segments = {}
self.roads = {}
self.roots = []
self.grid = {}
self.lot_cache = {}
self.rng = stable_rng(999)
self._init_roads()
def _schedule_next_event(self, road: Road, from_index: int):
rng = stable_rng(road.road_id, from_index, 1901)
if road.event_depth == 0:
gap = rng.randint(ROOT_EVENT_MIN, ROOT_EVENT_MAX)
else:
gap = rng.randint(BRANCH_EVENT_MIN, BRANCH_EVENT_MAX)
road.next_event_index = from_index + gap
def _new_segment(self, road_id, idx, x0, y0, x1, y1, heading, prev_sid):
sid = self.next_segment_id
self.next_segment_id += 1
seg = Segment(sid, road_id, idx, x0, y0, x1, y1, heading, math.hypot(x1 - x0, y1 - y0), prev_sid)
self.segments[sid] = seg
self.grid.setdefault((int(x0 // 64), int(y0 // 64)), []).append(sid)
self.grid.setdefault((int(x1 // 64), int(y1 // 64)), []).append(sid)
if prev_sid is not None and prev_sid in self.segments:
self.segments[prev_sid].next_sids.append(sid)
return sid
def _create_road(self, x, y, heading, kind="main", parent_road=None, event_depth=0):
road_id = self.next_road_id
self.next_road_id += 1
r = Road(
road_id=road_id,
start_sid=0,
kind=kind,
root_heading=heading,
current_heading=heading,
style_id=stable_rng(road_id, 1).randrange(1000),
style_remaining=0,
parent_road=parent_road,
event_depth=event_depth,
)
self.roads[road_id] = r
sid = self._new_segment(road_id, 0, x, y, x + math.cos(heading) * SEG_LEN, y + math.sin(heading) * SEG_LEN, heading, None)
r.start_sid = sid
return road_id, sid
def _append_segment(self, road: Road):
tail = self.get_tail_segment(road.road_id)
x0, y0 = tail.x1, tail.y1
heading = road.current_heading
x1 = x0 + math.cos(heading) * SEG_LEN
y1 = y0 + math.sin(heading) * SEG_LEN
sid = self._new_segment(road.road_id, tail.index_in_road + 1, x0, y0, x1, y1, heading, tail.sid)
return sid
def get_tail_segment(self, road_id):
sid = self.roads[road_id].start_sid
while self.segments[sid].next_sids:
same_road = None
for nxt in self.segments[sid].next_sids:
if self.segments[nxt].road_id == road_id:
same_road = nxt
break
if same_road is None:
break
sid = same_road
return self.segments[sid]
def _init_roads(self):
road_id, _ = self._create_road(0.0, 0.0, 0.0, "main")
self.roots.append(road_id)
self.roads[road_id].branch_cooldown = 6
self._schedule_next_event(self.roads[road_id], 0)
for _ in range(190):
self._extend_road(self.roads[road_id], allow_events=True)
def _choose_style(self, road: Road):
rng = stable_rng(road.road_id, road.style_id, road.step_in_style)
p = rng.random()
if p < 0.28:
delta = 0.0
count = rng.randint(4, 10)
elif p < 0.58:
delta = rng.uniform(-0.020, 0.020)
if abs(delta) < 0.006:
delta = 0.008 if rng.random() < 0.5 else -0.008
count = rng.randint(10, 20)
elif p < 0.86:
delta = rng.uniform(-0.040, 0.040)
if abs(delta) < 0.018:
delta = 0.022 if rng.random() < 0.5 else -0.022
count = rng.randint(8, 16)
else:
delta = rng.uniform(-0.070, 0.070)
if abs(delta) < 0.038:
delta = 0.045 if rng.random() < 0.5 else -0.045
count = rng.randint(6, 12)
return delta, count
def _can_spawn_branch_here(self, tail: Segment, turn_angle: float):
bx = tail.x1 + math.cos(tail.heading + turn_angle) * 18.0
by = tail.y1 + math.sin(tail.heading + turn_angle) * 18.0
nearby = self.find_segments_near(bx, by, 26.0)
foreign = 0
for sid in nearby:
seg = self.segments[sid]
if seg.road_id != tail.road_id:
foreign += 1
return foreign < 5
def _spawn_branch(self, parent_road: Road, tail: Segment, both=False):
created = []
if both:
angles = [-math.pi / 2, math.pi / 2]
else:
angles = [(-1 if stable_rng(parent_road.road_id, tail.sid, 77).random() < 0.5 else 1) * math.pi / 2]
for ang in angles:
if not self._can_spawn_branch_here(tail, ang):
continue
heading = wrap_angle(tail.heading + ang)
road_id, sid = self._create_road(
tail.x1,
tail.y1,
heading,
"branch" if not both else "cross",
parent_road=parent_road.road_id,
event_depth=parent_road.event_depth + 1,
)
tail.next_sids.append(sid)
self.segments[sid].prev_sid = tail.sid
road = self.roads[road_id]
road.branch_cooldown = 8
self._schedule_next_event(road, 0)
road.style_id += 17
road.curvature_step = 0.0
road.style_remaining = 10
for _ in range(36):
self._extend_road(road, allow_events=False)
created.append(road_id)
return created
def _extend_road(self, road: Road, allow_events=True):
if road.style_remaining <= 0:
road.curvature_step, road.style_remaining = self._choose_style(road)
road.step_in_style += 1
road.current_heading = wrap_angle(road.current_heading + road.curvature_step)
road.style_remaining -= 1
sid = self._append_segment(road)
road.branch_cooldown -= 1
tail = self.segments[sid]
allow_spawn = (
allow_events
and road.event_depth <= EVENT_DEPTH_LIMIT
and road.branch_cooldown <= 0
and tail.index_in_road >= road.next_event_index
and tail.index_in_road > 8
)
if allow_spawn:
rng = stable_rng(road.road_id, tail.sid, 333)
both_probability = 0.52 if road.event_depth == 0 else 0.34
if rng.random() < both_probability:
created = self._spawn_branch(road, tail, both=True)
else:
created = self._spawn_branch(road, tail, both=False)
if created:
self._schedule_next_event(road, tail.index_in_road)
road.branch_cooldown = 4
else:
retry_rng = stable_rng(road.road_id, tail.sid, 338)
road.next_event_index = tail.index_in_road + retry_rng.randint(RETRY_EVENT_MIN, RETRY_EVENT_MAX)
road.branch_cooldown = 2
return sid
def ensure_near(self, car_x, car_y):
for road in list(self.roads.values()):
if not road.alive:
continue
tail = self.get_tail_segment(road.road_id)
d = math.hypot(tail.x1 - car_x, tail.y1 - car_y)
needed = (GENERATE_AHEAD + 55) * SEG_LEN if road.event_depth == 0 else 66 * SEG_LEN
while d < needed:
self._extend_road(road, allow_events=(road.event_depth <= EVENT_DEPTH_LIMIT))
tail = self.get_tail_segment(road.road_id)
d = math.hypot(tail.x1 - car_x, tail.y1 - car_y)
self.prune_lot_cache(car_x, car_y)
def prune_lot_cache(self, car_x, car_y):
keys = list(self.lot_cache.keys())
for key in keys:
gx, gy = key
cx = gx * 36.0 + 18.0
cy = gy * 36.0 + 18.0
if math.hypot(cx - car_x, cy - car_y) > 1500.0:
del self.lot_cache[key]
def find_segments_near(self, x, y, radius):
g = int(radius // 64) + 1
cx, cy = int(x // 64), int(y // 64)
res = []
rr = radius * radius
seen = set()
for gx in range(cx - g, cx + g + 1):
for gy in range(cy - g, cy + g + 1):
for sid in self.grid.get((gx, gy), []):
if sid in seen:
continue
seen.add(sid)
seg = self.segments[sid]
d2 = point_segment_distance_sq(x, y, seg.x0, seg.y0, seg.x1, seg.y1)
if d2 <= rr:
res.append(sid)
return res
def nearest_road_state(self, x, y, forward_angle=None, preferred_sid=None):
nearby = self.find_segments_near(x, y, 44.0)
if preferred_sid is not None and preferred_sid in self.segments and preferred_sid not in nearby:
nearby.append(preferred_sid)
if not nearby:
return None
best = None
best_score = 1e18
for sid in nearby:
seg = self.segments[sid]
px, py, t = closest_point_on_segment(x, y, seg.x0, seg.y0, seg.x1, seg.y1)
dx = x - px
dy = y - py
dist = math.hypot(dx, dy)
vx = seg.x1 - seg.x0
vy = seg.y1 - seg.y0
seg_ang = math.atan2(vy, vx)
alignment_cost = 0.0
if forward_angle is not None:
alignment_cost = abs(angle_diff(forward_angle, seg_ang)) * 5.0
score = dist + alignment_cost
if preferred_sid is not None:
pref = self.segments[preferred_sid]
if seg.road_id == pref.road_id:
score -= 1.2
if score < best_score:
nx, ny = normal_left(vx, vy)
side = dx * nx + dy * ny
best = {
"sid": sid,
"road_id": seg.road_id,
"distance": dist,
"center": (px, py),
"t": t,
"heading": seg_ang,
"side_offset": side,
"segment": seg,
}
best_score = score
return best
def sample_visible_network(self, start_sid, x, y, heading):
start_state = self.nearest_road_state(x, y, heading, preferred_sid=start_sid)
if not start_state:
return []
sid = start_state["sid"]
px, py = start_state["center"]
queue = deque()
queue.append((sid, 0, 0.0))
visited = set()
while queue:
csid, depth, traveled = queue.popleft()
if (csid, depth) in visited:
continue
visited.add((csid, depth))
cseg = self.segments[csid]
if traveled > PATH_SAMPLE_AHEAD * SEG_LEN:
continue
for nsid in cseg.next_sids:
nseg = self.segments[nsid]
dist = traveled + nseg.length
if depth < 4:
queue.append((nsid, depth + 1, dist))
main = []
cur_sid = sid
heading0 = start_state["heading"]
main.append({"x": px, "y": py, "heading": heading0, "sid": cur_sid, "tag": "main"})
dist = 0.0
count = 0
while dist < PATH_SAMPLE_AHEAD * SEG_LEN and count < PATH_SAMPLE_AHEAD:
cur_seg = self.segments[cur_sid]
if not cur_seg.next_sids:
break
next_candidates = [self.segments[n] for n in cur_seg.next_sids]
straight = min(next_candidates, key=lambda s: abs(angle_diff(s.heading, cur_seg.heading)))
cur_sid = straight.sid
dist += straight.length
main.append({"x": straight.x1, "y": straight.y1, "heading": straight.heading, "sid": cur_sid, "tag": "main"})
count += 1
side_paths = []
for item in main[:-1]:
s = self.segments[item["sid"]]
if len(s.next_sids) > 1:
for nsid in s.next_sids:
if len(main) > 1 and nsid == next_sid_in_main(main, item["sid"]):
continue
path = [{"x": s.x1, "y": s.y1, "heading": s.heading, "sid": s.sid, "tag": "branch_start"}]
c = self.segments[nsid]
pd = 0.0
steps = 0
while steps < 38 and pd < 38 * SEG_LEN:
path.append({"x": c.x1, "y": c.y1, "heading": c.heading, "sid": c.sid, "tag": "branch"})
pd += c.length
if not c.next_sids:
break
next_candidates = [self.segments[n] for n in c.next_sids]
c = min(next_candidates, key=lambda s2: abs(angle_diff(s2.heading, c.heading)))
steps += 1
side_paths.append(path)
return [main] + side_paths
def generate_lots_near_path(self, path_points):
out = []
for idx in range(2, len(path_points), 2):
p = path_points[idx]
px, py = p["x"], p["y"]
hx, hy = vec_from_angle(p["heading"])
nx, ny = -hy, hx
for side in (-1, 1):
for band in (1, 2):
base = 18.0 + band * 16.0
cx = px + nx * side * base
cy = py + ny * side * base
gx = math.floor(cx / 36.0)
gy = math.floor(cy / 36.0)
key = (gx, gy)
if key not in self.lot_cache:
self.lot_cache[key] = self._make_lot(key)
lot = self.lot_cache[key]
if lot is not None:
out.append(lot)
uniq = {}
for lot in out:
uniq[lot["key"]] = lot
return list(uniq.values())
def _make_lot(self, key):
gx, gy = key
rng = stable_rng(gx, gy, 501)
center_x = gx * 36.0 + 18.0 + rng.uniform(-7, 7)
center_y = gy * 36.0 + 18.0 + rng.uniform(-7, 7)
near_roads = self.find_segments_near(center_x, center_y, 22.0)
if near_roads:
return None
if ((gx + gy) & 1) == 0 and stable_rng(gx, gy, 702).random() < 0.35:
return None
density_rng = stable_rng(gx // 2, gy // 2, 900)
density = density_rng.random()
if density < 0.58:
kind = "empty"
else:
p = stable_rng(gx, gy, 910).random()
if p < 0.55:
kind = "house"
elif p < 0.74:
kind = "parking"
elif p < 0.88:
kind = "tree"
else:
kind = "lamp"
angle = stable_rng(gx, gy, 925).uniform(0, math.tau)
sx = stable_rng(gx, gy, 926).uniform(0.75, 1.25)
sy = stable_rng(gx, gy, 927).uniform(0.75, 1.25)
return {
"key": key,
"kind": kind,
"x": center_x,
"y": center_y,
"angle": angle,
"sx": sx,
"sy": sy,
"style": stable_rng(gx, gy, 928).randint(0, 3),
}
def next_sid_in_main(main, sid):
for i in range(len(main) - 1):
if main[i]["sid"] == sid:
return main[i + 1]["sid"]
return None
def point_segment_distance_sq(px, py, x0, y0, x1, y1):
vx = x1 - x0
vy = y1 - y0
wx = px - x0
wy = py - y0
c1 = vx * wx + vy * wy
if c1 <= 0:
return (px - x0) ** 2 + (py - y0) ** 2
c2 = vx * vx + vy * vy
if c2 <= c1:
return (px - x1) ** 2 + (py - y1) ** 2
b = c1 / c2
bx = x0 + b * vx
by = y0 + b * vy
return (px - bx) ** 2 + (py - by) ** 2
def closest_point_on_segment(px, py, x0, y0, x1, y1):
vx = x1 - x0
vy = y1 - y0
c2 = vx * vx + vy * vy
if c2 <= 1e-9:
return x0, y0, 0.0
t = ((px - x0) * vx + (py - y0) * vy) / c2
t = clamp(t, 0.0, 1.0)
return x0 + vx * t, y0 + vy * t, t
def normal_left(vx, vy):
d = math.hypot(vx, vy)
if d <= 1e-9:
return 0.0, 1.0
return -vy / d, vx / d
def world_to_camera(wx, wy, car_x, car_y, car_angle):
dx = wx - car_x
dy = wy - car_y
ca = math.cos(car_angle)
sa = math.sin(car_angle)
cam_x = dx * (-sa) + dy * ca
cam_z = dx * ca + dy * sa
return cam_x, cam_z
def camera_to_screen(cam_x, cam_z):
if cam_z <= NEAR_PLANE:
return None
scale = FOCAL / cam_z
sx = WIDTH * 0.5 + cam_x * scale
sy = HORIZON_Y + CAMERA_HEIGHT * scale
return sx, sy, cam_z, scale
def project_ground(wx, wy, car_x, car_y, car_angle):
cam_x, cam_z = world_to_camera(wx, wy, car_x, car_y, car_angle)
return camera_to_screen(cam_x, cam_z)
def append_unique_projected(out, point):
if point is None:
return
if not out:
out.append(point)
return
px, py = out[-1][0], out[-1][1]
if abs(px - point[0]) > 0.5 or abs(py - point[1]) > 0.5:
out.append(point)
def clip_world_polyline_to_near(world_points, car_x, car_y, car_angle, clip_z=ROAD_CLIP_Z):
projected = []
if len(world_points) < 2:
return projected
cam_points = [world_to_camera(wx, wy, car_x, car_y, car_angle) for wx, wy in world_points]
for i in range(len(cam_points) - 1):
x0, z0 = cam_points[i]
x1, z1 = cam_points[i + 1]
v0 = z0 > clip_z
v1 = z1 > clip_z
if v0 and v1:
append_unique_projected(projected, camera_to_screen(x0, z0))
append_unique_projected(projected, camera_to_screen(x1, z1))
elif (not v0) and v1:
t = (clip_z - z0) / (z1 - z0)
ix = lerp(x0, x1, t)
append_unique_projected(projected, camera_to_screen(ix, clip_z))
append_unique_projected(projected, camera_to_screen(x1, z1))
elif v0 and (not v1):
t = (clip_z - z0) / (z1 - z0)
ix = lerp(x0, x1, t)
append_unique_projected(projected, camera_to_screen(x0, z0))
append_unique_projected(projected, camera_to_screen(ix, clip_z))
return projected
def projected_scale(cam_z, h):
return (FOCAL * h) / cam_z
class EngineAudio:
def __init__(self):
self.enabled = False
self.channel = None
self.sounds = []
self.current_index = -1
try:
pygame.mixer.init(frequency=22050, size=-16, channels=1, buffer=512)
self.enabled = True
self.sounds = [self.make_loop_sound(freq) for freq in (70, 84, 96, 108, 122, 138, 156, 176, 198, 222)]
self.channel = pygame.mixer.Channel(0)
except pygame.error:
self.enabled = False
def make_loop_sound(self, freq):
rate = 22050
duration = 0.42
count = int(rate * duration)
buf = array('h')
phase = 0.0
for i in range(count):
t = i / rate
phase += freq / rate
frac = phase - math.floor(phase)
saw = frac * 2.0 - 1.0
tri = 2.0 * abs(saw) - 1.0
overtone = math.sin(2.0 * math.pi * freq * 2.03 * t) * 0.22
noise = math.sin(2.0 * math.pi * freq * 0.49 * t + 0.7) * 0.08
sample = (0.58 * tri + 0.24 * saw + overtone + noise) * 8000
env = 0.85 - 0.15 * math.cos(2.0 * math.pi * i / count)
buf.append(int(clamp(sample * env, -30000, 30000)))
bio = io.BytesIO()
with wave.open(bio, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(rate)
wf.writeframes(buf.tobytes())
bio.seek(0)
return pygame.mixer.Sound(file=bio)
def update(self, speed):
if not self.enabled:
return
v = abs(speed)
idx = int(clamp((v / MAX_SPEED) * (len(self.sounds) - 1), 0, len(self.sounds) - 1))
vol = clamp(0.10 + v / MAX_SPEED * 0.50, 0.08, 0.60)
if idx != self.current_index or not self.channel.get_busy():
self.channel.play(self.sounds[idx], loops=-1, fade_ms=50)
self.current_index = idx
self.channel.set_volume(vol)
def stop(self):
if self.enabled and self.channel:
self.channel.stop()
class Game:
def __init__(self):
pygame.mixer.pre_init(frequency=22050, size=-16, channels=1, buffer=512)
pygame.init()
self.screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Infinite Drive")
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("consolas", 20)
self.world = World()
self.audio = EngineAudio()
self.car_x = 0.0
self.car_y = 0.0
self.car_angle = 0.0
self.speed = 0.0
self.distance = 0.0
self.steer_angle = 0.0
self.follow_sid = self.world.roads[self.world.roots[0]].start_sid
self.sun_dir = SUN_DIR
self.running = True
def run(self):
while self.running:
dt = self.clock.tick(FPS) / 1000.0
self.handle_events()
self.update(dt)
self.draw()
self.audio.stop()
pygame.quit()
def handle_events(self):
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
def update(self, dt):
keys = pygame.key.get_pressed()
steer_input = 0.0
if keys[pygame.K_LEFT]:
steer_input -= 1.0
if keys[pygame.K_RIGHT]:
steer_input += 1.0
if steer_input != 0.0:
self.steer_angle += steer_input * STEER_BUILD_RATE * dt
else:
if self.steer_angle > 0.0:
self.steer_angle = max(0.0, self.steer_angle - STEER_RETURN_RATE * dt)
elif self.steer_angle < 0.0:
self.steer_angle = min(0.0, self.steer_angle + STEER_RETURN_RATE * dt)
self.steer_angle = clamp(self.steer_angle, -MAX_STEER_ANGLE, MAX_STEER_ANGLE)
if keys[pygame.K_UP]:
self.speed += ACCEL * dt
if keys[pygame.K_DOWN]:
if self.speed > 0:
self.speed -= BRAKE * dt
else:
self.speed -= ACCEL * 0.55 * dt
if not keys[pygame.K_UP]:
drag = ROLL_DRAG * (1.0 + abs(self.speed) / 65.0)
if self.speed > 0:
self.speed = max(0.0, self.speed - drag * dt)
elif self.speed < 0:
self.speed = min(0.0, self.speed + drag * dt)
self.speed = clamp(self.speed, REVERSE_SPEED, MAX_SPEED)
speed_ratio = min(abs(self.speed) / MAX_SPEED, 1.0)
yaw_rate = self.steer_angle * (0.28 + 1.12 * speed_ratio) * TURN_RESPONSE
if self.speed < 0.0:
yaw_rate *= -0.55
self.car_angle = wrap_angle(self.car_angle + yaw_rate * dt)
state = self.world.nearest_road_state(self.car_x, self.car_y, self.car_angle, preferred_sid=self.follow_sid)
on_road_factor = 0.0
if state:
self.follow_sid = state["sid"]
lateral = state["side_offset"]
dist = abs(lateral)
if dist < ROAD_GUIDE_RANGE:
on_road_factor = 1.0 - dist / ROAD_GUIDE_RANGE
tangent_err = angle_diff(state["heading"], self.car_angle)
if abs(self.steer_angle) < math.radians(6.0):
self.car_angle = wrap_angle(self.car_angle + tangent_err * HEADING_ASSIST * on_road_factor)
self.car_angle = wrap_angle(self.car_angle - lateral * CENTER_ASSIST * on_road_factor * dt * 0.12)
road_dist = state["distance"] if state else 999.0
if road_dist > ROAD_HALF_WIDTH + 2.0:
self.speed -= OFFROAD_DRAG * (1.0 + min((road_dist - ROAD_HALF_WIDTH) / 18.0, 2.5)) * dt * max(4.0, abs(self.speed)) / 8.0
if self.speed < REVERSE_SPEED:
self.speed = REVERSE_SPEED
vx, vy = vec_from_angle(self.car_angle)
self.car_x += vx * self.speed * dt
self.car_y += vy * self.speed * dt
self.distance += max(0.0, self.speed * dt)
self.world.ensure_near(self.car_x, self.car_y)
self.audio.update(self.speed)
def draw_sky(self):
for y in range(int(HORIZON_Y) + 1):
t = y / max(1, int(HORIZON_Y))
c = (
int(lerp(SKY_TOP[0], SKY_HORIZON[0], t)),
int(lerp(SKY_TOP[1], SKY_HORIZON[1], t)),
int(lerp(SKY_TOP[2], SKY_HORIZON[2], t)),
)
pygame.draw.line(self.screen, c, (0, y), (WIDTH, y))
pygame.draw.rect(self.screen, GROUND_FAR, (0, int(HORIZON_Y), WIDTH, HEIGHT - int(HORIZON_Y)))
def draw_mountains_and_sun(self):
base_y = int(HORIZON_Y) + 8
rel_sun = angle_diff(self.sun_dir, self.car_angle)
sx = WIDTH * 0.5 + rel_sun / (math.pi / 2) * WIDTH * 0.36
sy = HORIZON_Y - 100 - 24 * math.sin(rel_sun * 0.7)
if -140 < sx < WIDTH + 140:
pygame.draw.circle(self.screen, SUN_COLOR, (int(sx), int(sy)), 34)
pygame.draw.circle(self.screen, (255, 250, 210), (int(sx), int(sy)), 24)
far_points = []
near_points = []
step = 32
for i in range(-2, WIDTH // step + 3):
x = i * step
ang = self.car_angle + (x - WIDTH * 0.5) / (WIDTH * 0.52)
far_h = 46 + 24 * math.sin(ang * 1.2 + 0.4) + 12 * math.sin(ang * 3.3 + 1.7)
near_h = 66 + 32 * math.sin(ang * 1.7 + 2.2) + 14 * math.sin(ang * 4.9)
far_points.append((x, base_y - far_h))
near_points.append((x, base_y + 10 - near_h))
far_poly = [(0, base_y + 32)] + far_points + [(WIDTH, base_y + 32)]
near_poly = [(0, base_y + 52)] + near_points + [(WIDTH, base_y + 52)]
pygame.draw.polygon(self.screen, MOUNTAIN_FAR, far_poly)
pygame.draw.polygon(self.screen, MOUNTAIN_NEAR, near_poly)
def draw_ground(self):
for i in range(int(HORIZON_Y), HEIGHT):
t = (i - HORIZON_Y) / (HEIGHT - HORIZON_Y)
c = (
int(lerp(GROUND_FAR[0], GROUND_NEAR[0], t)),
int(lerp(GROUND_FAR[1], GROUND_NEAR[1], t)),
int(lerp(GROUND_FAR[2], GROUND_NEAR[2], t)),
)
pygame.draw.line(self.screen, c, (0, i), (WIDTH, i))
def gather_paths(self):
paths = self.world.sample_visible_network(self.follow_sid, self.car_x, self.car_y, self.car_angle)
if not paths:
return []
return paths
def draw_road_network(self, paths):
all_ground_points = []
road_draws = []
for path_idx, path in enumerate(paths):
left_world = []
right_world = []
center_world = []
for p in path:
nx, ny = -math.sin(p["heading"]), math.cos(p["heading"])
left_world.append((p["x"] + nx * ROAD_HALF_WIDTH, p["y"] + ny * ROAD_HALF_WIDTH))
right_world.append((p["x"] - nx * ROAD_HALF_WIDTH, p["y"] - ny * ROAD_HALF_WIDTH))
center_world.append((p["x"], p["y"]))
all_ground_points.append(p)
left_pts = clip_world_polyline_to_near(left_world, self.car_x, self.car_y, self.car_angle)
right_pts = clip_world_polyline_to_near(right_world, self.car_x, self.car_y, self.car_angle)
center_pts = clip_world_polyline_to_near(center_world, self.car_x, self.car_y, self.car_angle)
if len(left_pts) >= 2 and len(right_pts) >= 2 and len(center_pts) >= 2:
poly = [(pt[0], pt[1]) for pt in left_pts] + [(pt[0], pt[1]) for pt in reversed(right_pts)]
avg_z = sum(pt[2] for pt in center_pts) / len(center_pts)
road_draws.append((avg_z, poly, left_pts, right_pts, center_pts, path_idx == 0))
road_draws.sort(key=lambda x: x[0], reverse=True)
for _, poly, left_pts, right_pts, center_pts, is_main in road_draws:
if len(poly) >= 4:
pygame.draw.polygon(self.screen, ROAD_COLOR, poly)
edge_count = min(len(left_pts), len(right_pts)) - 1
for i in range(max(0, edge_count)):
a = left_pts[i]
b = left_pts[i + 1]
ra = right_pts[i]
rb = right_pts[i + 1]
width = max(1, int(6 / max(1.0, min(a[2], b[2]) * 0.08)))
pygame.draw.line(self.screen, ROAD_EDGE, (a[0], a[1]), (b[0], b[1]), width)
pygame.draw.line(self.screen, ROAD_EDGE, (ra[0], ra[1]), (rb[0], rb[1]), width)
if is_main:
for i in range(len(center_pts) - 1):
if i % 3 == 2:
continue
a = center_pts[i]
b = center_pts[i + 1]
width = max(1, int(4 / max(1.0, min(a[2], b[2]) * 0.08)))
pygame.draw.line(self.screen, CENTER_LINE, (a[0], a[1]), (b[0], b[1]), width)
return all_ground_points
def collect_upcoming_junctions(self, main_path):
events = []
last_idx = -999
for idx in range(6, len(main_path) - 1):
sid = main_path[idx]["sid"]
seg = self.world.segments[sid]
if len(seg.next_sids) <= 1:
continue
if idx - last_idx < 6:
continue
main_next = next_sid_in_main(main_path, sid)
sides = []
for nsid in seg.next_sids:
if nsid == main_next:
continue
nseg = self.world.segments[nsid]
turn = angle_diff(nseg.heading, seg.heading)
if turn < -0.15:
sides.append(-1)
elif turn > 0.15:
sides.append(1)
if not sides:
continue
event_type = "intersection" if (-1 in sides and 1 in sides) else "branch"
events.append((idx, event_type, sides))
last_idx = idx
return events
def draw_sign_icon(self, cx, cy, size, event_type, side):
diamond = [(cx, cy - size), (cx + size, cy), (cx, cy + size), (cx - size, cy)]
pygame.draw.polygon(self.screen, (244, 214, 92), diamond)
pygame.draw.polygon(self.screen, (70, 60, 28), diamond, max(1, int(size * 0.18)))
ink = (45, 45, 40)
if event_type == "intersection":
stem_top = (cx, cy + size * 0.35)
stem_mid = (cx, cy - size * 0.12)
left_end = (cx - size * 0.45, cy - size * 0.12)
right_end = (cx + size * 0.45, cy - size * 0.12)
pygame.draw.line(self.screen, ink, stem_top, stem_mid, max(1, int(size * 0.16)))
pygame.draw.line(self.screen, ink, left_end, right_end, max(1, int(size * 0.16)))
pygame.draw.polygon(
self.screen,
ink,
[
(left_end[0], left_end[1]),
(left_end[0] + size * 0.14, left_end[1] - size * 0.10),
(left_end[0] + size * 0.14, left_end[1] + size * 0.10),
],
)
pygame.draw.polygon(
self.screen,
ink,
[
(right_end[0], right_end[1]),
(right_end[0] - size * 0.14, right_end[1] - size * 0.10),
(right_end[0] - size * 0.14, right_end[1] + size * 0.10),
],
)
else:
d = -1 if side < 0 else 1
tail = (cx - d * size * 0.36, cy + size * 0.25)
bend = (cx - d * size * 0.06, cy + size * 0.04)
head = (cx + d * size * 0.28, cy - size * 0.18)
pygame.draw.line(self.screen, ink, tail, bend, max(1, int(size * 0.16)))
pygame.draw.line(self.screen, ink, bend, head, max(1, int(size * 0.16)))
pygame.draw.polygon(
self.screen,
ink,
[
(head[0], head[1]),
(head[0] - d * size * 0.20, head[1] + size * 0.06),
(head[0] - d * size * 0.10, head[1] + size * 0.22),
],
)
def draw_warning_signs(self, main_path):
events = self.collect_upcoming_junctions(main_path)
jobs = []
for idx, event_type, sides in events:
lead_steps = (14, 7) if event_type == "intersection" else (12, 6)
sign_sides = [-1, 1] if event_type == "intersection" else [sides[0]]
for lead in lead_steps:
ref_idx = max(2, idx - lead)
ref = main_path[ref_idx]
nx, ny = -math.sin(ref["heading"]), math.cos(ref["heading"])
for side in sign_sides:
wx = ref["x"] + nx * side * SIGN_SIDE_OFFSET
wy = ref["y"] + ny * side * SIGN_SIDE_OFFSET
proj = project_ground(wx, wy, self.car_x, self.car_y, self.car_angle)
if not proj:
continue
sx, sy, cam_z, _ = proj
if cam_z > 520 or not (-140 <= sx <= WIDTH + 140):
continue
jobs.append((cam_z, sx, sy, event_type, side, lead))
jobs.sort(key=lambda item: item[0], reverse=True)
for cam_z, sx, sy, event_type, side, lead in jobs:
pole_h = projected_scale(cam_z, SIGN_POLE_HEIGHT)
face_size = projected_scale(cam_z, SIGN_FACE_SIZE)
pole_w = max(2, int(projected_scale(cam_z, 0.08)))
top_y = sy - pole_h
pygame.draw.line(self.screen, (140, 140, 146), (sx, sy), (sx, top_y), pole_w)
self.draw_sign_icon(sx, top_y - face_size * 0.15, face_size, event_type, side)
def draw_shadow_polygon(self, points, cam_z_avg):
if len(points) >= 3:
shade = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
alpha = int(clamp(70 - cam_z_avg * 0.06, 18, 58))
pygame.draw.polygon(shade, (0, 0, 0, alpha), points)
self.screen.blit(shade, (0, 0))
def draw_lots(self, path_points):
lots = self.world.generate_lots_near_path(path_points)
draw_items = []
shadow_dx = math.sin(self.sun_dir) * 7.0
shadow_dy = math.cos(self.sun_dir) * 7.0
for lot in lots:
if lot["kind"] == "empty":
continue
proj = project_ground(lot["x"], lot["y"], self.car_x, self.car_y, self.car_angle)
if not proj:
continue
sx, sy, cam_z, scale = proj
if cam_z > 420 or not (-200 <= sx <= WIDTH + 200):
continue
draw_items.append((cam_z, lot, proj, shadow_dx, shadow_dy))
draw_items.sort(key=lambda t: t[0], reverse=True)
for cam_z, lot, proj, shadow_dx, shadow_dy in draw_items:
if lot["kind"] == "house":
self.draw_house(lot, proj, shadow_dx, shadow_dy)
elif lot["kind"] == "parking":
self.draw_parking(lot, proj, shadow_dx, shadow_dy)
elif lot["kind"] == "tree":
self.draw_tree(lot, proj, shadow_dx, shadow_dy)
elif lot["kind"] == "lamp":
self.draw_lamp(lot, proj, shadow_dx, shadow_dy)
def ground_quad(self, cx, cy, angle, hw, hh):
ca = math.cos(angle)
sa = math.sin(angle)
corners = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]
pts = []
for x, y in corners:
wx = cx + x * ca - y * sa
wy = cy + x * sa + y * ca
p = project_ground(wx, wy, self.car_x, self.car_y, self.car_angle)
if not p:
return None
pts.append((p[0], p[1], p[2]))
return pts
def draw_house(self, lot, proj, shadow_dx, shadow_dy):
w = 6.5 * lot["sx"]
d = 5.0 * lot["sy"]
h = 4.4 * (0.8 + 0.25 * lot["sx"])
quad = self.ground_quad(lot["x"], lot["y"], lot["angle"], w, d)
shadow = self.ground_quad(lot["x"] + shadow_dx, lot["y"] + shadow_dy, lot["angle"], w * 1.08, d * 1.08)
if not quad:
return
if shadow:
self.draw_shadow_polygon([(p[0], p[1]) for p in shadow], sum(p[2] for p in shadow) / 4)
base = [(p[0], p[1]) for p in quad]
top = []
for p in quad:
ty = p[1] - projected_scale(p[2], h)
top.append((p[0], ty))
roof_height = h * 0.42
front_mid = (
(top[0][0] + top[1][0]) * 0.5,
(top[0][1] + top[1][1]) * 0.5 - projected_scale((quad[0][2] + quad[1][2]) * 0.5, roof_height),
)
back_mid = (
(top[2][0] + top[3][0]) * 0.5,
(top[2][1] + top[3][1]) * 0.5 - projected_scale((quad[2][2] + quad[3][2]) * 0.5, roof_height),
)
wall = HOUSE_WALL[lot["style"] % len(HOUSE_WALL)]
roof_c = HOUSE_ROOF[lot["style"] % len(HOUSE_ROOF)]
pygame.draw.polygon(self.screen, wall, [base[0], base[1], top[1], top[0]])
pygame.draw.polygon(self.screen, tuple(max(0, c - 18) for c in wall), [base[1], base[2], top[2], top[1]])
pygame.draw.polygon(self.screen, tuple(max(0, c - 8) for c in wall), [base[2], base[3], top[3], top[2]])
pygame.draw.polygon(self.screen, roof_c, [top[0], top[1], front_mid])
pygame.draw.polygon(self.screen, tuple(max(0, c - 20) for c in roof_c), [top[3], top[2], back_mid])
pygame.draw.polygon(self.screen, tuple(max(0, c - 10) for c in roof_c), [top[1], top[2], back_mid, front_mid])
door_w = (base[1][0] - base[0][0]) * 0.18
dx = (base[1][0] - base[0][0]) * 0.46
dy = (base[1][1] - base[0][1]) * 0.46
door = [
(base[0][0] + dx - door_w * 0.4, base[0][1] + dy),
(base[0][0] + dx + door_w * 0.6, base[0][1] + dy),
(top[0][0] + dx + door_w * 0.6, top[0][1] + dy * 0.82),
(top[0][0] + dx - door_w * 0.4, top[0][1] + dy * 0.82),
]
pygame.draw.polygon(self.screen, (110, 78, 58), door)
def draw_parking(self, lot, proj, shadow_dx, shadow_dy):
quad = self.ground_quad(lot["x"], lot["y"], lot["angle"], 8.5 * lot["sx"], 6.5 * lot["sy"])
if not quad:
return
shadow = self.ground_quad(lot["x"] + shadow_dx * 0.5, lot["y"] + shadow_dy * 0.5, lot["angle"], 9.2 * lot["sx"], 7.2 * lot["sy"])
if shadow:
self.draw_shadow_polygon([(p[0], p[1]) for p in shadow], sum(p[2] for p in shadow) / 4)
poly = [(p[0], p[1]) for p in quad]
pygame.draw.polygon(self.screen, PARKING_COLOR, poly)
for i in range(1, 5):
t = i / 5
a = mix_point(poly[0], poly[3], t)
b = mix_point(poly[1], poly[2], t)
pygame.draw.line(self.screen, (220, 220, 220), a, b, 2)
pygame.draw.lines(self.screen, (245, 245, 245), True, poly, 2)
def draw_tree(self, lot, proj, shadow_dx, shadow_dy):
sx, sy, cam_z, scale = proj
trunk_h = projected_scale(cam_z, 2.0 * lot["sy"])
crown_r = projected_scale(cam_z, 2.5 * lot["sx"])
shadow = self.ground_quad(lot["x"] + shadow_dx, lot["y"] + shadow_dy, lot["angle"], 2.6 * lot["sx"], 1.5 * lot["sy"])
if shadow:
self.draw_shadow_polygon([(p[0], p[1]) for p in shadow], sum(p[2] for p in shadow) / 4)
pygame.draw.rect(
self.screen,
TREE_TRUNK,
pygame.Rect(int(sx - crown_r * 0.16), int(sy - trunk_h), max(1, int(crown_r * 0.32)), max(1, int(trunk_h))),
)
color = TREE_LEAF[lot["style"] % len(TREE_LEAF)]
pygame.draw.circle(self.screen, color, (int(sx), int(sy - trunk_h - crown_r * 0.1)), int(max(2, crown_r)))
pygame.draw.circle(
self.screen,
tuple(min(255, c + 20) for c in color),
(int(sx - crown_r * 0.38), int(sy - trunk_h - crown_r * 0.14)),
int(max(2, crown_r * 0.68)),
)
pygame.draw.circle(
self.screen,
tuple(max(0, c - 15) for c in color),
(int(sx + crown_r * 0.34), int(sy - trunk_h - crown_r * 0.02)),
int(max(2, crown_r * 0.62)),
)
def draw_lamp(self, lot, proj, shadow_dx, shadow_dy):
sx, sy, cam_z, scale = proj
pole_h = projected_scale(cam_z, 4.8)
pole_w = max(2, int(projected_scale(cam_z, 0.09)))
shadow = self.ground_quad(lot["x"] + shadow_dx, lot["y"] + shadow_dy, lot["angle"], 0.5, 3.0)
if shadow:
self.draw_shadow_polygon([(p[0], p[1]) for p in shadow], sum(p[2] for p in shadow) / 4)
pygame.draw.line(self.screen, LAMP_POLE, (sx, sy), (sx, sy - pole_h), pole_w)
arm_x = sx + projected_scale(cam_z, 0.9)
arm_y = sy - pole_h + projected_scale(cam_z, 0.2)
pygame.draw.line(self.screen, LAMP_POLE, (sx, sy - pole_h), (arm_x, arm_y), pole_w)
pygame.draw.circle(self.screen, LAMP_HEAD, (int(arm_x), int(arm_y)), max(2, int(projected_scale(cam_z, 0.22))))
def draw_hood(self):
hood = [(WIDTH * 0.26, HEIGHT), (WIDTH * 0.35, HOOD_Y), (WIDTH * 0.65, HOOD_Y), (WIDTH * 0.74, HEIGHT)]
pygame.draw.polygon(self.screen, (158, 24, 24), hood)
pygame.draw.line(self.screen, (210, 80, 80), hood[1], hood[2], 2)
pygame.draw.line(self.screen, (95, 12, 12), hood[0], hood[1], 3)
pygame.draw.line(self.screen, (95, 12, 12), hood[2], hood[3], 3)
def draw_ui(self):
speed_text = self.font.render(f"speed {max(0.0, self.speed):5.1f} u/s", True, (255, 255, 255))
dist_text = self.font.render(f"distance {self.distance:8.0f} m", True, (255, 255, 255))
ang = (math.degrees(self.car_angle) % 360.0)
steer_deg = math.degrees(self.steer_angle)
dir_text = self.font.render(f"heading {ang:6.1f} deg", True, (255, 255, 255))
steer_text = self.font.render(f"steer {steer_deg:6.1f} deg", True, (255, 255, 255))
note = self.font.render("up accelerate down brake hold left/right to steer", True, (245, 245, 245))
self.screen.blit(speed_text, (18, 16))
self.screen.blit(dist_text, (18, 40))
self.screen.blit(dir_text, (18, 64))
self.screen.blit(steer_text, (18, 88))
self.screen.blit(note, (18, HEIGHT - 34))
def draw(self):
self.draw_sky()
self.draw_mountains_and_sun()
self.draw_ground()
paths = self.gather_paths()
path_points = []
if paths:
for p in paths:
path_points.extend(p)
self.draw_road_network(paths)
self.draw_lots(path_points)
self.draw_warning_signs(paths[0])
self.draw_hood()
self.draw_ui()
pygame.display.flip()
def mix_point(a, b, t):
return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
if __name__ == "__main__":
Game().run()
動作画面