Files
end-finder/end_finder.py
2026-11-11 01:51:55 +01:00

129 lines
4.5 KiB
Python

"""Triangulate the End portal (stronghold) position from two thrown Eyes of Ender.
Pure geometry and input-parsing helpers, plus a small interactive command-line
flow. Uses only the Python standard library.
"""
import math
import sys
# Reliability thresholds (degrees) for the angle between the two throw lines.
# Below PARALLEL_ANGLE_DEG the lines are considered parallel (unusable result,
# error and non-zero exit code); below WARN_ANGLE_DEG the result is shown but
# flagged as imprecise.
PARALLEL_ANGLE_DEG = 0.5
WARN_ANGLE_DEG = 10.0
def parse_point(text):
"""Parse a line of user input into an (x, z) tuple of floats.
Accepts exactly two numbers separated by whitespace and/or commas,
e.g. "100 200", "100,200" or "100.5 , -200.25" (decimals allowed, as
read from the F3 screen). Raises ValueError otherwise.
"""
try:
parts = text.replace(",", " ").split()
if len(parts) != 2:
raise ValueError
return (float(parts[0]), float(parts[1]))
except ValueError:
raise ValueError(
"expected exactly two numbers, e.g. 'x z' or 'x,z'"
) from None
def angle_between(a, b, c, d):
"""Return the angle in degrees between line (a, b) and line (c, d).
a, b, c, d are (x, z) tuples. The angle is computed from the direction
vectors via atan2(|cross|, |dot|), which yields the acute (0-90 degrees)
angle between the two lines regardless of their orientations: an Eye of
Ender may fly towards or away from the other throw, so the dot product
can be negative even though the lines are nearly parallel.
"""
dx1, dz1 = b[0] - a[0], b[1] - a[1]
dx2, dz2 = d[0] - c[0], d[1] - c[1]
return math.degrees(
math.atan2(abs(dx1 * dz2 - dz1 * dx2), abs(dx1 * dx2 + dz1 * dz2))
)
def intersect_lines(a, b, c, d):
"""Return the (x, z) intersection point of line (a, b) and line (c, d).
Uses the closed-form vector product formula (robust for vertical lines,
unlike slope-based forms). Raises ValueError if either direction vector
is zero or if the lines are parallel (no single intersection point).
"""
dx1, dz1 = b[0] - a[0], b[1] - a[1]
dx2, dz2 = d[0] - c[0], d[1] - c[1]
if dx1 == 0 and dz1 == 0:
raise ValueError("first line has a zero direction vector")
if dx2 == 0 and dz2 == 0:
raise ValueError("second line has a zero direction vector")
cross_dir = dx1 * dz2 - dz1 * dx2
if cross_dir == 0:
raise ValueError("lines are parallel (no unique intersection)")
# t = cross(C - A, d2) / cross(d1, d2), then the point is A + t * d1.
cx, cz = c[0] - a[0], c[1] - a[1]
t = (cx * dz2 - cz * dx2) / cross_dir
return (a[0] + t * dx1, a[1] + t * dz1)
def read_point(prompt):
"""Prompt the user until they enter a valid (x, z) point."""
while True:
try:
return parse_point(input(prompt))
except ValueError:
print(
"Saisie invalide : entrez deux nombres, "
"par exemple « 100 200 » ou « 100,200 »."
)
def read_throw(number):
"""Prompt for the launch and fall positions of one thrown Eye of Ender.
Re-prompts for the fall position if it is identical to the launch
position (such a throw carries no direction).
"""
launch = read_point(f"Œil {number} — position de lancé (x z ou x,z) : ")
while True:
fall = read_point(f"Œil {number} — position de chute (x z ou x,z) : ")
if fall != launch:
return launch, fall
print(
"Lancer inexploitable : la position de chute est identique "
"à celle du lancé."
)
def main():
"""Run the interactive triangulation flow."""
a, b = read_throw(1)
c, d = read_throw(2)
angle = angle_between(a, b, c, d)
if angle < PARALLEL_ANGLE_DEG:
print(
f"Erreur : les deux lancers sont parallèles ou quasi parallèles "
f"(angle = {angle:.2f}°), l'intersection est inexploitable."
)
sys.exit(1)
try:
x, z = intersect_lines(a, b, c, d)
except ValueError as error: # Defensive: the angle check above should
print(f"Erreur : {error}") # already have filtered these cases out.
sys.exit(1)
if angle < WARN_ANGLE_DEG:
print(
f"Attention : les lancers sont presque parallèles "
f"(angle = {angle:.2f}°), le résultat est peu précis."
)
print(f"Portail de l'End (approx.) : x = {x:.1f}, z = {z:.1f}")
if __name__ == "__main__":
main()