Python による簡単なドライブゲーム

本プログラムは,Python と pygame による擬似 3 次元・一人称視点のドライブゲームである.プレイヤーはキーボードで車両を操作し,手続き的に無限生成される道路,分岐および交差点を走行する.ゲームオーバーやスコアは設けず,走行距離(distance,累積走行距離)が画面に表示される.

本資料は,pygame による描画ループ,dataclasscollections.deque による状態管理,mathrandom による手続き的生成,wavearray による波形データ生成,ヨー回転(車体の旋回回転),操舵角の蓄積,近接クリッピング(手前側の描画範囲制限)といったゲームプログラミング要素の習得を目的とする.

実行には pygame,numpy および pillow のインストールが必要である.動作環境は Windows,画面解像度 1280×720,Python 3.10 以降を想定する(本資料のインストール手順では Python 3.12.10 を推奨する).操作は,↑(加速),↓(減速・制動.停止後は後退),← / →(操舵.押し続けた時間に応じて舵角が増える蓄積式)である.

【目次】

  1. Python 基礎
  2. Python 3.12 のインストール
  3. Python の開発環境 Visual Studio Code のインストールと Python 用の設定
  4. Windows での pygame のインストールと動作確認
  5. Python プログラム実行手順
  6. Python プログラムの構造
  7. プログラムの実行時の留意事項
  8. Python プログラム

【サイト内の関連ページ】

プログラミング基礎・用語集・体験

Python 基礎

本プログラムで用いる Python の言語要素および標準ライブラリ・外部ライブラリと,プログラム内での用途を以下に示す.

要素プログラム内での用途
変数car_xcar_ycar_anglespeedsteer_angledistance による車両状態の保持
self.speed += ACCEL * dtself.car_angle + yaw_rate * dt 等による速度・姿勢の時間積分
if 文キー入力判定,路外判定,操舵入力閾値による進路支援の発動制御
while 文メインゲームループ(while self.running)および道路生成の継続条件判定
def 文 / class 文WorldRoadSegmentEngineAudioGame のクラスおよびメソッド定義
dataclassSegmentRoad のデータ構造定義
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:インストーラーによるインストール

  1. Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
  2. ダウンロードしたインストーラーを実行する。
  3. 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから python コマンドを実行できない。
  4. 「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 プログラムを解釈・実行するソフトウェア)を選択する必要がある.

  1. コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(Ctrl+Shift+P
  2. Python: Select Interpreter と入力する
  3. 表示される一覧から,使用する 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を使用)

プログラムファイルの作成と保存

  1. 左サイドバーの「エクスプローラー」アイコン(Ctrl+Shift+E)をクリックする
  2. 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する

    続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する

  3. フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
  4. ファイル名(例:aitask.py.ファイル名は何でも良い)を入力し Enter を押す.拡張子は .py(Python ファイルを示す拡張子)とする
  5. 実行したいコードを選択し,Ctrl+C でコピーする.VS Code のエディタ領域に Ctrl+V で貼り付ける
  6. Ctrl+S で保存する

プログラムの実行

  1. エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
  2. VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(print 関数の出力等)が表示される
  3. tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
  4. VS Code 下部のターミナルで実行結果を確認する.OpenCV ウィンドウ(OpenCV が画像を表示するために開く専用ウィンドウ)が開いた場合はそちらも確認する.OpenCV ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する

Python プログラムの構造

システム構成

プログラムは Python と pygame を用い,単一ファイルで構成する.外部アセット(画像・音声ファイル等)への依存を避け,コード内生成で完結させる.実行は python ファイル名.py により行う.

表示方式

画面表示は一人称視点とする.俯瞰視点,トップダウン表示,ミニマップ,外部カメラ,追従カメラ切替は実装しない.画面下部には車両のボンネットを表示する.

ゲーム進行

ゲームは無限走行型とする.道路に距離上限を設けず,車両前方および周辺に必要な分だけ道路セグメントを逐次生成し,不要になった遠方データは破棄する.画面左上に速度,走行距離,方位および操舵角を表示する.

入力操作

左右操作は車体向きを直接変える方式ではなく,操舵角を内部状態として保持する蓄積式とする.キー押下継続時間に応じて操舵角が増加し,キー解放時には中立位置(操舵角 0)へ復帰する.

車両挙動

最高速度

高速域でも動作破綻が起きないよう,道路生成距離,描画距離,操舵応答および音声変化を整合させる.

道路生成方式

道路は短い直線セグメントの連結によって構成する.曲線道路は,ヘディング(進行方向角)を少しずつ変化させた直線セグメントの連結により表現し,折れ角を避ける.道路生成はインクリメンタル方式(必要時に都度追加する方式)とし,車両の近傍および前方に限定して実施する.

道路形状

分岐仕様

道路には片側分岐(枝道)を含める.分岐はプレイヤーの位置および向きに応じて進入可能とし,事前予約制や強制進路選択は行わない.分岐先道路に進入した後も,一定間隔で次の分岐または交差点が再出現する.

交差点仕様

道路には二方向交差点を含める.交差点では直進,左折,右折が可能である.進行先は車両の現在位置,向きおよび入力に基づいて決まり,レール状の進路固定は行わない.

分岐および交差点の出現制御

進路支援

運転支援として,道路中央への横方向復元補助および道路接線方向への向き補助を設ける.補助は強制的なロックではなく,プレイヤーが道路外へ逸脱したり逆走したりできる自由度を残す.補助は,操舵入力が 6 度未満のとき,かつ車両が道路中心から ROAD_GUIDE_RANGE 以内にあるときに作用する.

道路視認支援

描画仕様

描画は擬似 3 次元の遠近投影方式とする.道路,分岐道路,交差道路,住宅,駐車場,樹木および街灯を一人称視点で投影描画する.near plane(NEAR_PLANE.カメラに最も近い描画開始面.近接クリッピングの基準面)以遠を描画対象とし,道路ポリラインは線分単位で ROAD_CLIP_Z 平面と交差クリッピングすることで,画面手前の道路を連続して表示する.

空と遠景

太陽と影

太陽は固定方向光源として扱い,住宅,樹木,街灯および駐車場に簡易的な影を落とす.影は厳密な物理計算を行わない簡易表現とする.

沿道地物

沿道区画は手続き的に生成する.区画配置は決定的であり,同一座標では同一内容が再現される.区画の過半数は空き地とし,建物は低層住宅のみとする.

土地利用比率

配置制約

道路描画要件

音響仕様

エンジン音を実装する.音はコード内で合成し,速度に応じて段階的に音色またはピッチが変化する.基本周波数の異なる 10 種類のループ音を事前生成し,速度に応じて再生中チャネルを切り替える.pygame.mixer の初期化に失敗した環境では無音のままゲームループが継続する.

性能要件

保守要件

ワールド管理,道路生成,描画,音声およびゲームループの責務を分離した構造とする.

プログラムの実行時の留意事項

実行方法

  1. 前節のコマンドで pygame,numpy および pillow をインストールする
  2. 対象ファイルを保存する
  3. 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()

動作画面

動作画面