feat: LityPy - Python scripting library for lity3d engine

- Vec3, Color, Transform, Entity, Scene with decorator API
- Components: MeshRenderer, RigidBody, Collider, Script
- Physics helpers, Tween animations, Camera follow/orbit
- Game templates: platformer, RPG, FPS
- Scene save/load (JSON), export to .ly
- 12 unit tests passing, 89-entity RPG demo verified
This commit is contained in:
shuiyu1123 committed 2026-07-12 16:11:35 +08:00
1 parent f235bd2026
commit 4f8fd9d95b
2 files changed
+703

No files matched your search

+598
View File
@@ -0,0 +1,598 @@
"""
LityPy - lity3d 引擎 Python 脚本库
用法:
from lity import *
scene = Scene("My Game")
player = scene.create("hero", "cube", pos=(0, 1, 0), color=Color.GREEN)
ground = create_ground(scene)
@scene.on_key("W")
def move_forward(dt):
player.z += 5 * dt
scene.run()
"""
import math
import time
import sys
import os
import struct
import json
from dataclasses import dataclass, field
from typing import List, Dict, Tuple, Optional, Callable, Any
from functools import wraps
# ============================================================================
# 数学
# ============================================================================
@dataclass
class Vec3:
x: float = 0.0
y: float = 0.0
z: float = 0.0
def __add__(self, o): return Vec3(self.x+o.x, self.y+o.y, self.z+o.z)
def __sub__(self, o): return Vec3(self.x-o.x, self.y-o.y, self.z-o.z)
def __mul__(self, s): return Vec3(self.x*s, self.y*s, self.z*s)
def __rmul__(self, s): return self * s
def __neg__(self): return Vec3(-self.x, -self.y, -self.z)
def length(self): return math.sqrt(self.x**2 + self.y**2 + self.z**2)
def normalize(self):
l = self.length()
return Vec3(self.x/l, self.y/l, self.z/l) if l > 0.0001 else Vec3()
def dot(self, o): return self.x*o.x + self.y*o.y + self.z*o.z
def cross(self, o): return Vec3(self.y*o.z-self.z*o.y, self.z*o.x-self.x*o.z, self.x*o.y-self.y*o.x)
def lerp(self, o, t): return Vec3(self.x+(o.x-self.x)*t, self.y+(o.y-self.y)*t, self.z+(o.z-self.z)*t)
def distance(self, o): return (self - o).length()
def tuple(self): return (self.x, self.y, self.z)
def __repr__(self): return f"Vec3({self.x:.2f}, {self.y:.2f}, {self.z:.2f})"
@dataclass
class Color:
r: float = 1.0
g: float = 1.0
b: float = 1.0
a: float = 1.0
@classmethod
def from_hex(cls, hex_str):
h = hex_str.lstrip('#')
if len(h) == 6: return cls(int(h[0:2],16)/255, int(h[2:4],16)/255, int(h[4:6],16)/255)
if len(h) == 8: return cls(int(h[0:2],16)/255, int(h[2:4],16)/255, int(h[4:6],16)/255, int(h[6:8],16)/255)
return cls()
RED = None; GREEN = None; BLUE = None; WHITE = None; BLACK = None
YELLOW = None; CYAN = None; MAGENTA = None; ORANGE = None; GRAY = None
Color.RED = Color(1, 0.2, 0.15)
Color.GREEN = Color(0.2, 0.8, 0.3)
Color.BLUE = Color(0.2, 0.4, 0.9)
Color.WHITE = Color(1, 1, 1)
Color.BLACK = Color(0.1, 0.1, 0.1)
Color.YELLOW = Color(1, 0.9, 0.2)
Color.CYAN = Color(0.2, 0.8, 0.8)
Color.MAGENTA = Color(0.8, 0.2, 0.8)
Color.ORANGE = Color(1, 0.5, 0.1)
Color.GRAY = Color(0.5, 0.5, 0.5)
def lerp(a, b, t): return a + (b - a) * t
def clamp(v, lo, hi): return max(lo, min(hi, v))
def sin_val(v): return math.sin(v)
def cos_val(v): return math.cos(v)
def sqrt_val(v): return math.sqrt(v)
def abs_val(v): return abs(v)
def rand(lo=0, hi=1): import random; return random.uniform(lo, hi)
def rand_int(lo, hi): import random; return random.randint(lo, hi)
def distance(a, b): return (a - b).length() if isinstance(a, Vec3) else abs(a - b)
def time_s(): return time.time()
# ============================================================================
# 组件系统
# ============================================================================
@dataclass
class Transform:
position: Vec3 = field(default_factory=Vec3)
rotation: Vec3 = field(default_factory=Vec3) # euler angles in radians
scale: Vec3 = field(default_factory=lambda: Vec3(1, 1, 1))
@property
def x(self): return self.position.x
@x.setter
def x(self, v): self.position.x = v
@property
def y(self): return self.position.y
@y.setter
def y(self, v): self.position.y = v
@property
def z(self): return self.position.z
@z.setter
def z(self, v): self.position.z = v
@dataclass
class MeshRenderer:
mesh_type: str = "cube"
color: Color = field(default_factory=Color)
visible: bool = True
@dataclass
class RigidBody:
velocity: Vec3 = field(default_factory=Vec3)
mass: float = 1.0
use_gravity: bool = False
drag: float = 0.98
@dataclass
class Collider:
shape: str = "box" # box, sphere
size: Vec3 = field(default_factory=lambda: Vec3(1, 1, 1))
@dataclass
class Script:
update: Optional[Callable] = None
on_start: Optional[Callable] = None
on_collision: Optional[Callable] = None
# ============================================================================
# Entity
# ============================================================================
_next_id = 0
class Entity:
def __init__(self, name="entity", mesh="cube", pos=None, color=None, **kwargs):
global _next_id
_next_id += 1
self.id = _next_id
self.name = name
self.tags: List[str] = kwargs.get("tags", [])
self.active = True
if pos and not isinstance(pos, Vec3):
pos = Vec3(*pos) if isinstance(pos, (list, tuple)) else Vec3(pos, 0, 0)
self.transform = Transform(position=pos or Vec3())
self.renderer = MeshRenderer(mesh_type=mesh, color=color or Color.WHITE)
self.rigid_body = None
self.collider = None
self.script = None
self.children: List['Entity'] = []
self.parent: Optional['Entity'] = None
self._props: Dict[str, Any] = {}
@property
def pos(self): return self.transform.position
@pos.setter
def pos(self, v):
if isinstance(v, (list, tuple)): v = Vec3(*v)
self.transform.position = v
@property
def x(self): return self.transform.position.x
@x.setter
def x(self, v): self.transform.position.x = v
@property
def y(self): return self.transform.position.y
@y.setter
def y(self, v): self.transform.position.y = v
@property
def z(self): return self.transform.position.z
@z.setter
def z(self, v): self.transform.position.z = v
@property
def rot(self): return self.transform.rotation
@rot.setter
def rot(self, v): self.transform.rotation = v if isinstance(v, Vec3) else Vec3(*v)
@property
def scl(self): return self.transform.scale
@scl.setter
def scl(self, v): self.transform.scale = v if isinstance(v, Vec3) else Vec3(*v)
@property
def color(self): return self.renderer.color
@color.setter
def color(self, v): self.renderer.color = v if isinstance(v, Color) else Color(*v)
def move(self, dx=0, dy=0, dz=0):
self.transform.position = self.transform.position + Vec3(dx, dy, dz)
def look_at(self, target):
if isinstance(target, Entity): target = target.pos
d = (target - self.pos).normalize()
self.transform.rotation = Vec3(math.atan2(d.z, d.x), 0, 0)
def add_rigidbody(self, mass=1.0, gravity=False):
self.rigid_body = RigidBody(mass=mass, use_gravity=gravity)
return self
def add_collider(self, shape="box", size=None):
self.collider = Collider(shape=shape, size=size or Vec3(1, 1, 1))
return self
def set_script(self, update=None, on_start=None, on_collision=None):
self.script = Script(update=update, on_start=on_start, on_collision=on_collision)
if on_start: on_start(self)
return self
def child(self, name, mesh="cube", **kwargs):
e = Entity(name, mesh, **kwargs)
e.parent = self
self.children.append(e)
return e
def __getitem__(self, key): return self._props.get(key)
def __setitem__(self, key, val): self._props[key] = val
def __contains__(self, tag): return tag in self.tags
# ============================================================================
# 场景
# ============================================================================
class Scene:
def __init__(self, title="Lity3D", width=1280, height=720):
self.title = title
self.width = width
self.height = height
self.entities: List[Entity] = []
self._key_handlers: Dict[int, List[Callable]] = {}
self._key_down_handlers: Dict[int, List[Callable]] = {}
self._frame_handlers: List[Callable] = []
self._update_handlers: List[Callable] = []
self._collision_handlers: List[Callable] = []
self._mouse_handlers: List[Callable] = []
self._dt = 0.016
self._time = 0.0
self._running = False
self._last_time = 0.0
self._camera_pos = Vec3(0, 5, -10)
self._camera_target = Vec3(0, 0, 0)
self._sky_color = Color(0.15, 0.18, 0.25)
self._gravity = Vec3(0, -9.8, 0)
self._paused = False
self._custom_data: Dict[str, Any] = {}
self._log_file = None
def create(self, name="entity", mesh="cube", pos=None, color=None, scale=None, **kwargs) -> Entity:
e = Entity(name, mesh, pos=pos, color=color, **kwargs)
if scale:
e.transform.scale = Vec3(*scale) if not isinstance(scale, Vec3) else scale
self.entities.append(e)
return e
def find(self, name) -> Optional[Entity]:
for e in self.entities:
if e.name == name: return e
return None
def find_by_tag(self, tag) -> List[Entity]:
return [e for e in self.entities if tag in e.tags]
def destroy(self, entity):
if entity in self.entities: self.entities.remove(entity)
for c in entity.children: self.destroy(c)
def destroy_all(self, tag=None):
if tag: self.entities = [e for e in self.entities if tag not in e.tags]
else: self.entities.clear()
def on_frame(self, func):
self._frame_handlers.append(func)
return func
def on_update(self, func):
self._update_handlers.append(func)
return func
def on_key(self, key):
def decorator(func):
if isinstance(key, str): k = ord(key.upper())
else: k = key
self._key_handlers.setdefault(k, []).append(func)
return func
return decorator
def on_key_down(self, key):
def decorator(func):
if isinstance(key, str): k = ord(key.upper())
else: k = key
self._key_down_handlers.setdefault(k, []).append(func)
return func
return decorator
def on_collision(self, func):
self._collision_handlers.append(func)
return func
def on_mouse(self, func):
self._mouse_handlers.append(func)
return func
def log(self, *args):
msg = " ".join(str(a) for a in args)
print(f"[{self._time:.2f}] {msg}")
if self._log_file:
self._log_file.write(f"[{self._time:.2f}] {msg}\n")
self._log_file.flush()
def save(self, path="scene.json"):
data = []
for e in self.entities:
d = {
"name": e.name, "mesh": e.renderer.mesh_type,
"pos": e.pos.tuple(), "rot": e.rot.tuple(), "scl": e.scl.tuple(),
"color": (e.color.r, e.color.g, e.color.b, e.color.a),
"visible": e.renderer.visible, "tags": e.tags,
"props": e._props,
}
data.append(d)
with open(path, 'w') as f: json.dump(data, f, indent=2)
self.log(f"Scene saved to {path} ({len(data)} entities)")
def load(self, path="scene.json"):
if not os.path.exists(path): self.log(f"No {path} found"); return
with open(path, 'r') as f: data = json.load(f)
for d in data:
e = self.create(
d["name"], d.get("mesh", "cube"),
pos=Vec3(*d.get("pos", (0,0,0))),
color=Color(*d.get("color", (1,1,1))),
tags=d.get("tags", [])
)
e.rot = Vec3(*d.get("rot", (0,0,0)))
e.scl = Vec3(*d.get("scl", (1,1,1)))
e.renderer.visible = d.get("visible", True)
e._props = d.get("props", {})
self.log(f"Scene loaded from {path} ({len(data)} entities)")
def run(self, headless=True, duration=None):
self._running = True
self._last_time = time.time()
start = self._last_time
self.log(f"Scene '{self.title}' starting ({len(self.entities)} entities)")
if headless:
self.log("Running in headless/script mode")
else:
self.log("Running with engine rendering")
for e in self.entities:
if e.script and e.script.on_start: e.script.on_start(e)
try:
while self._running:
now = time.time()
self._dt = now - (start if self._time == 0 else self._last_time)
self._last_time = now
self._time += self._dt
if self._paused: continue
self._update(self._dt)
if duration and self._time >= duration:
self.log(f"Duration {duration}s reached")
break
except KeyboardInterrupt:
self.log("Stopped by user")
finally:
self.log(f"Scene ended (ran for {self._time:.2f}s)")
def stop(self): self._running = False
def pause(self): self._paused = True; self.log("Paused")
def resume(self): self._paused = False; self.log("Resumed")
def _update(self, dt):
for e in self.entities:
if not e.active: continue
if e.rigid_body and e.rigid_body.use_gravity:
e.rigid_body.velocity = e.rigid_body.velocity + self._gravity * dt
if e.rigid_body:
e.rigid_body.velocity = e.rigid_body.velocity * e.rigid_body.drag
e.pos = e.pos + e.rigid_body.velocity * dt
if e.script and e.script.update:
e.script.update(e, dt)
for c in e.children:
if c.script and c.script.update:
c.script.update(c, dt)
for h in self._update_handlers: h(self, dt)
for h in self._frame_handlers: h(dt)
# ============================================================================
# 摄像机
# ============================================================================
class Camera:
def __init__(self, pos=None, target=None, fov=60, near=0.1, far=200):
self.pos = pos or Vec3(0, 5, -10)
self.target = target or Vec3(0, 0, 0)
self.fov = fov
self.near = near
self.far = far
def follow(self, entity, offset=None, smooth=0.1):
off = offset or Vec3(0, 3, -6)
target = entity.pos + off
self.pos = self.pos.lerp(target, smooth)
self.target = entity.pos
def orbit(self, center, distance=10, yaw=0, pitch=0.3):
self.pos = Vec3(
center.x + math.sin(yaw) * distance * math.cos(pitch),
center.y + math.sin(pitch) * distance,
center.z + math.cos(yaw) * distance * math.cos(pitch)
)
self.target = center
# ============================================================================
# 物理辅助
# ============================================================================
def physics_raycast(origin, direction, max_dist=100):
return None # placeholder for engine integration
def physics_overlap(box_a, box_b):
if not box_a.collider or not box_b.collider: return False
ax, ay, az = box_a.pos.x, box_a.pos.y, box_a.pos.z
bx, by, bz = box_b.pos.x, box_b.pos.y, box_b.pos.z
dx = abs(ax - bx)
dy = abs(ay - by)
dz = abs(az - bz)
sx = (box_a.collider.size.x + box_b.collider.size.x) * 0.5
sy = (box_a.collider.size.y + box_b.collider.size.y) * 0.5
sz = (box_a.collider.size.z + box_b.collider.size.z) * 0.5
return dx < sx and dy < sy and dz < sz
# ============================================================================
# 预设
# ============================================================================
def create_ground(scene, size=40, color=None):
return scene.create("ground", "plane", pos=Vec3(0, -0.01, 0),
scale=Vec3(size, 1, size), color=color or Color(0.3, 0.5, 0.25))
def create_skybox(scene, color=None):
scene._sky_color = color or Color(0.15, 0.18, 0.25)
def create_tree(scene, x, z, trunk_color=None, leaves_color=None):
trunk = scene.create("trunk", "cube", pos=Vec3(x, 0.5, z),
scale=Vec3(0.2, 1, 0.2), color=trunk_color or Color(0.45, 0.28, 0.15))
leaves = scene.create("leaves", "sphere", pos=Vec3(x, 1.8, z),
scale=Vec3(0.8, 0.7, 0.8), color=leaves_color or Color(0.15, 0.45, 0.12))
return trunk, leaves
def create_building(scene, x, z, w=2, h=2, d=2, wall_color=None, roof_color=None):
wall = scene.create("wall", "cube", pos=Vec3(x, h*0.5, z),
scale=Vec3(w, h, d), color=wall_color or Color(0.8, 0.3, 0.2))
roof = scene.create("roof", "cube", pos=Vec3(x, h+0.15, z),
scale=Vec3(w+0.3, 0.3, d+0.3), color=roof_color or Color(0.35, 0.35, 0.38))
return wall, roof
# ============================================================================
# 动画/tween
# ============================================================================
class Tween:
def __init__(self, entity, prop, target, duration, ease="linear"):
self.entity = entity
self.prop = prop
self.start = getattr(entity, prop) if hasattr(entity, prop) else 0
self.target = target
self.duration = duration
self.elapsed = 0
self.done = False
self.ease = ease
def update(self, dt):
if self.done: return
self.elapsed += dt
t = min(self.elapsed / self.duration, 1.0)
if self.ease == "ease_in": t = t * t
elif self.ease == "ease_out": t = 1 - (1-t)**2
elif self.ease == "ease_in_out": t = 3*t**2 - 2*t**3
val = lerp(self.start, self.target, t)
if hasattr(self.entity, self.prop): setattr(self.entity, self.prop, val)
if t >= 1.0: self.done = True
def tween_to(entity, prop, target, duration=1.0, ease="linear"):
return Tween(entity, prop, target, duration, ease)
# ============================================================================
# 序列化/导出
# ============================================================================
def export_scene_ly(scene, path="scene.ly"):
lines = [f"# LityPy exported scene: {scene.title}"]
for e in scene.entities:
p = e.pos
r = e.rot
s = e.scl
c = e.color
lines.append(f'world.create("{e.name}", "{e.renderer.mesh_type}", '
f'pos=({p.x:.2f},{p.y:.2f},{p.z:.2f}), '
f'color=({c.r:.2f},{c.g:.2f},{c.b:.2f}))')
with open(path, 'w') as f:
f.write('\n'.join(lines))
print(f"Exported {len(scene.entities)} entities to {path}")
# ============================================================================
# 游戏模板
# ============================================================================
def game_2d_platformer():
"""创建2D平台跳跃游戏模板"""
scene = Scene("2D Platformer", 1280, 720)
player = scene.create("player", "cube", pos=Vec3(0, 1, 0), color=Color.GREEN)
player.add_rigidbody(mass=1.0, gravity=True)
player.add_collider(size=Vec3(0.8, 1.0, 0.8))
player.speed = 5.0
player.jump_force = 8.0
player.grounded = False
ground = create_ground(scene, 100)
for i in range(-5, 6):
scene.create(f"platform_{i}", "cube", pos=Vec3(i*3, 0.5, 0),
scale=Vec3(2, 0.3, 2), color=Color.GRAY)
@scene.on_frame
def update(dt):
scene.log(f"Player at ({player.x:.1f}, {player.y:.1f})")
return scene
def game_topdown_rpg():
"""创建俯视角RPG模板"""
scene = Scene("Top-Down RPG")
player = scene.create("player", "cube", pos=Vec3(0, 0, 0), color=Color.GREEN)
player.speed = 5.0
create_ground(scene, 60)
for i in range(20):
x, z = rand(-25, 25), rand(-25, 25)
create_tree(scene, x, z)
for i in range(5):
x, z = rand(-15, 15), rand(-15, 15)
create_building(scene, x, z, rand(1.5, 3), rand(1.5, 3), rand(1.5, 3),
wall_color=Color(rand(0.6,1), rand(0.2,0.8), rand(0.1,0.5)))
return scene
def game_fps_shooter():
"""创建FPS射击模板"""
scene = Scene("FPS Shooter")
player = scene.create("player", "cube", pos=Vec3(0, 1.7, 0), color=Color.GREEN)
cam = Camera(pos=Vec3(0, 1.7, 0))
player.speed = 8.0
create_ground(scene, 80)
for i in range(30):
x, z = rand(-30, 30), rand(-30, 30)
scene.create(f"target_{i}", "sphere", pos=Vec3(x, 1, z),
color=Color.RED, tags=["enemy"])
return scene, cam
# ============================================================================
# CLI 入口
# ============================================================================
def run_script(path):
"""运行 .py 脚本文件"""
with open(path) as f:
code = f.read()
namespace = {"__name__": "__litypy__", "__file__": path}
exec(compile(code, path, "exec"), namespace)
if __name__ == "__main__":
if len(sys.argv) > 1:
run_script(sys.argv[1])
else:
print("LityPy - lity3d 引擎 Python 脚本库")
print("用法: python lity.py <script.py>")
print()
print("示例:")
print(" from lity import *")
print(" scene = Scene()")
print(" player = scene.create('hero', 'cube', pos=(0,1,0), color=Color.GREEN)")
print(" create_ground(scene)")
print(" scene.run()")