import math
from typing import Union, Tuple, Any
import numpy as np
[Doku]
class Vector:
"""2D vector for movement, geometry, and physics.
Supports arithmetic with both other Vectors and 2D tuples.
Examples:
::
direction = Vector.from_actors(enemy, player)
enemy.move_vector(direction.normalize() * 2)
"""
[Doku]
def __init__(self, x: float, y: float) -> None:
"""Create a 2D vector.
Args:
x: Horizontal component.
y: Vertical component.
"""
self.vec = np.array([x, y], dtype=float)
@staticmethod
def _to_vector(value: Union["Vector", Tuple[float, float]]) -> "Vector":
if isinstance(value, Vector):
return value
if isinstance(value, tuple) and len(value) == 2:
return Vector(*value)
raise TypeError(f"Expected Vector or 2-tuple, got {type(value)}.")
def __getitem__(self, index: int) -> float:
return self.vec[index]
@property
def x(self) -> float:
"""The x-component of the vector."""
return self.vec[0]
@x.setter
def x(self, value: float) -> None:
self.vec[0] = value
@property
def y(self) -> float:
"""The y-component of the vector."""
return self.vec[1]
@y.setter
def y(self, value: float) -> None:
self.vec[1] = value
@property
def angle(self) -> float:
"""float: Direction angle in Miniworlds convention.
`0` points up and `90` points right. Equivalent to `to_direction()`.
"""
return self.to_direction()
[Doku]
def to_position(self) -> Tuple[float, float]:
"""Return the vector as an `(x, y)` tuple."""
return (self.x, self.y)
[Doku]
@classmethod
def from_position(cls, position: Tuple[float, float]) -> "Vector":
"""Create a vector from a position.
Args:
position: Position as `(x, y)`.
Returns:
A new Vector with the same x and y components.
Examples:
::
vector = Vector.from_position(actor.position)
"""
if not (isinstance(position, tuple) and len(position) == 2):
raise TypeError("Position must be a tuple of two float values.")
return cls(*position)
[Doku]
@classmethod
def from_positions(cls, p1: Tuple[float, float], p2: Tuple[float, float]) -> "Vector":
"""Create a vector pointing from `p1` to `p2`.
Args:
p1: Start position as `(x, y)`.
p2: End position as `(x, y)`.
Returns:
Vector equal to `p2 - p1`.
"""
return cls(p2[0] - p1[0], p2[1] - p1[1])
[Doku]
@classmethod
def from_direction(cls, direction: Union[str, int, float]) -> "Vector":
"""Create a unit vector from a Miniworlds direction.
Args:
direction: Direction in Miniworlds convention. Common values are
`0` or `"up"`, `90` or `"right"`, `-90` or `"left"`, and
`180` or `"down"`.
Returns:
A new unit Vector pointing in the given direction.
Examples:
::
@player.register
def on_key_pressed_right(self):
step = Vector.from_direction("right") * 5
self.move_vector(step)
"""
if isinstance(direction, str):
normalized = direction.strip().lower()
mapping = {
"up": 0,
"top": 0,
"right": 90,
"down": 180,
"bottom": 180,
"left": 270,
}
if normalized in mapping:
direction = mapping[normalized]
else:
raise ValueError(
f"Unsupported direction string '{direction}'. "
"Use one of: up, right, down, left, top, bottom."
)
direction = float(direction)
x = math.sin(math.radians(direction))
y = -math.cos(math.radians(direction))
return cls(x, y)
[Doku]
@classmethod
def from_actors(cls, t1: "actor_mod.Actor", t2: "actor_mod.Actor") -> "Vector":
"""Create a vector from one actor to another actor.
Args:
t1: Start actor.
t2: Target actor.
Returns:
A vector equal to `t2.center - t1.center`.
Examples:
::
enemy_vector = Vector.from_actors(player, enemy)
if enemy_vector.length() < 50:
player.move_away(enemy, 3)
"""
x = t2.center[0] - t1.center[0]
y = t2.center[1] - t1.center[1]
return cls(x, y)
[Doku]
@classmethod
def from_actor_and_position(cls, t1: "actor_mod.Actor", pos) -> "Vector":
"""Create a vector from an actor center to a target position.
Args:
t1: The start actor.
pos: Target position as `(x, y)`.
Returns:
A vector equal to `pos - t1.center`.
Examples:
::
vector = Vector.from_actor_and_position(player, (100, 80))
"""
x = pos[0] - t1.center[0]
y = pos[1] - t1.center[1]
return cls(x, y)
[Doku]
@classmethod
def from_actor_direction(cls, actor: "actor_mod.Actor") -> "Vector":
"""Create a unit vector from an actor direction.
Args:
actor: Actor whose direction is used.
Returns:
A unit vector pointing in the actor direction.
Examples:
::
step = Vector.from_actor_direction(player) * 5
player.move_vector(step)
"""
return Vector.from_direction(actor.direction)
[Doku]
def rotate(self, theta: float) -> "Vector":
"""Rotate the vector in-place.
Args:
theta: Rotation angle in degrees.
Returns:
The vector itself.
Examples:
::
vector.rotate(90)
"""
radians = np.deg2rad(theta % 360)
rot = np.array([[math.cos(radians), -math.sin(radians)],
[math.sin(radians), math.cos(radians)]])
self.vec = np.dot(rot, self.vec)
return self
[Doku]
def to_direction(self) -> float:
"""Convert the vector to a Miniworlds direction.
Returns:
Direction in degrees. Returns `0` for a zero-length vector.
"""
if self.length() == 0:
return 0.0
axis = np.array([0, -1])
unit_vector = self.vec / np.linalg.norm(self.vec)
dot = np.dot(unit_vector, axis)
angle = math.degrees(math.acos(dot))
if self.x < 0:
angle = 360 - angle
return angle
[Doku]
def normalize(self) -> "Vector":
"""Normalize the vector to length 1 in-place.
Returns:
The vector itself. A zero-length vector is returned unchanged.
"""
norm = np.linalg.norm(self.vec)
if norm == 0:
return self
self.vec = self.vec / norm
return self
[Doku]
def length(self) -> float:
"""Return the Euclidean length of the vector.
Returns:
Length as a float.
"""
return float(np.linalg.norm(self.vec))
[Doku]
def limit(self, max_length: float) -> "Vector":
"""Cap the vector length without changing its direction.
Args:
max_length: Maximum allowed length.
Returns:
The vector itself.
Examples:
::
velocity.limit(10)
"""
if self.length() > max_length:
self.vec = self.normalize().vec * max_length
return self
[Doku]
def multiply(self, other: Union[float, int, "Vector"]) -> Union["Vector", float]:
"""Multiply by a scalar or compute a dot product.
Args:
other: Scalar value or another `Vector`.
Returns:
A scaled `Vector` for scalar input, or a dot product for vectors.
"""
if isinstance(other, (int, float)):
return Vector(self.x * other, self.y * other)
if isinstance(other, Vector):
return self.dot(other)
raise TypeError(
f"Can't multiply Vector by {type(other).__name__}. "
f"Multiply Vector by a number instead. "
f"Example: my_vector * 2 or my_vector * 0.5"
)
[Doku]
def add_to_position(self, position: Tuple[float, float]) -> Tuple[float, float]:
"""Add the vector to a position.
Args:
position: Position as `(x, y)`.
Returns:
New position as `(x + self.x, y + self.y)`.
"""
return (self.x + position[0], self.y + position[1])
[Doku]
def get_normal(self) -> "Vector":
"""Return a vector perpendicular to this one.
Returns:
A new Vector rotated 90° counter-clockwise.
"""
return Vector(-self.y, self.x)
[Doku]
def dot(self, other: "Vector") -> float:
"""Compute the dot product.
Args:
other: The other Vector.
Returns:
Dot product as a float.
"""
return float(np.dot(self.vec, other.vec))
[Doku]
def distance_to(self, other: Union["Vector", Tuple[float, float]]) -> float:
"""Calculate the Euclidean distance to another vector or position.
Args:
other: A Vector or tuple.
Returns:
The distance as float.
Examples:
::
distance = Vector(0, 0).distance_to((3, 4))
"""
other = self._to_vector(other)
return float(np.linalg.norm(self.vec - other.vec))
[Doku]
def angle_to(self, other: Union["Vector", Tuple[float, float]]) -> float:
"""Compute the angle to another vector or position.
Args:
other: A Vector or tuple.
Returns:
Angle in degrees between 0 and 180.
Examples:
::
angle = Vector(1, 0).angle_to((0, 1))
"""
other = self._to_vector(other)
len_self = np.linalg.norm(self.vec)
len_other = np.linalg.norm(other.vec)
if len_self == 0 or len_other == 0:
return 0.0
norm_self = self.vec / len_self
norm_other = other.vec / len_other
dot = np.clip(np.dot(norm_self, norm_other), -1.0, 1.0)
return math.degrees(math.acos(dot))
def __add__(self, other: Union["Vector", Tuple[float, float]]) -> "Vector":
other = self._to_vector(other)
return Vector(self.x + other.x, self.y + other.y)
def __radd__(self, other: Union["Vector", Tuple[float, float]]) -> "Vector":
return self.__add__(other)
def __sub__(self, other: Union["Vector", Tuple[float, float]]) -> "Vector":
other = self._to_vector(other)
return Vector(self.x - other.x, self.y - other.y)
def __rsub__(self, other: Union["Vector", Tuple[float, float]]) -> "Vector":
other = self._to_vector(other)
return Vector(other.x - self.x, other.y - self.y)
def __mul__(self, other: Union[float, int, "Vector", Tuple[float, float]]) -> Union["Vector", float]:
if isinstance(other, (int, float)):
return Vector(self.x * other, self.y * other)
other = self._to_vector(other)
return self.dot(other)
def __rmul__(self, other: Union[float, int, "Vector", Tuple[float, float]]) -> Union["Vector", float]:
return self.__mul__(other)
def __neg__(self) -> "Vector":
return Vector(-self.x, -self.y)
def __eq__(self, other: Any) -> bool:
if isinstance(other, Vector):
return np.allclose(self.vec, other.vec)
if isinstance(other, tuple) and len(other) == 2:
return np.allclose(self.vec, np.array(other, dtype=float))
return False
def __str__(self) -> str:
return f"({round(self.x, 3)}, {round(self.y, 3)})"
def __repr__(self) -> str:
return f"Vector({self.x}, {self.y})"