# File:   MysticShield3.py
# Sketch: Mystic Shield -- Eldritch Mandala (Professional Teaching Edition)
# Author: klp / TechNoviceTools (TNT)
# Date:   2026-09-29
# Canvas: 800 x 600  |  Color mode: HSB(360, 100, 100, 100)
#
# Central concept: layered drawing with independent rotation.
# Three drawing systems run simultaneously in draw():
#   (1) Fire sparks -- burst outward from center, fade at the edge  [behind]
#   (2) Mandala     -- concentric rings + eight orbital sigils       [middle]
#   (3) Star        -- interlocked squares, counter-rotates          [front]
# Later draw() calls appear on top of earlier ones (painter's algorithm).
#
# Teaching path: see mysticShield3Chatlog.html -- 12 stages from blank
# canvas to this complete file, one new concept per stage.
#
# Python note: Python has no 'const' or 'final'. UPPER_CASE is a convention
#              that signals "treat this as a constant." push()/pop() become
#              pushMatrix()/popMatrix(). Global angle vars need 'global' keyword.


# ===============================================================
#  SECTION 1 -- CANVAS
#  Change here to resize the entire sketch at once.
# ===============================================================
CANVAS_W = 800
CANVAS_H = 600


# ===============================================================
#  SECTION 2 -- ANIMATION SPEEDS
#  Positive = clockwise.  Negative = counter-clockwise.
#  Smaller absolute value = slower rotation.
# ===============================================================
MANDALA_SPEED =  0.008   # mandala rings rotate clockwise
STAR_SPEED    = -0.005   # star array counter-rotates


# ===============================================================
#  SECTION 3 -- GEOMETRY
#  All values in pixels, measured from the canvas center (0, 0).
# ===============================================================
RING_OUTER_RADIUS      = 160  # thin boundary ring
RING_MAIN_RADIUS       = 140  # thick glowing ring
RING_CORE_RADIUS       =  50  # small inner ring

SATELLITE_COUNT        =   8  # evenly-spaced orbital sigils
SATELLITE_ORBIT_RADIUS = 140  # center-to-sigil distance (px)
SATELLITE_OUTER_SIZE   =  45  # outer circle diameter per sigil
SATELLITE_INNER_SIZE   =  25  # inner circle diameter per sigil

STAR_SIZE = 200  # square side length for interlocked star pair


# ===============================================================
#  SECTION 4 -- COLOR PALETTE  (all values for HSB color mode)
#
#  colorMode(HSB, 360, 100, 100, 100) scales the four channels:
#    Hue   0-360 : position on the color wheel
#                  (0 = red, 33 = orange, 60 = yellow, 120 = green, 240 = blue)
#    Sat   0-100 : 0 = grey  to  100 = fully saturated color
#    Bri   0-100 : 0 = black to  100 = full brightness
#    Alpha 0-100 : 0 = invisible  to  100 = fully opaque
# ===============================================================

# Background -- near-black with a faint warm amber
BG_HUE = 22;   BG_SAT = 73;  BG_BRI = 6

# Outer thin boundary ring -- burnt orange, semi-transparent
RING_OUTER_HUE = 33;  RING_OUTER_ALPHA = 71

# Thick glowing main ring -- deep orange, nearly opaque
RING_MAIN_HUE  = 26;  RING_MAIN_ALPHA  = 86

# Satellite sigils -- amber gold, semi-transparent
RING_SAT_HUE = 36;  RING_SAT_SAT = 92;  RING_SAT_ALPHA = 78

# Inner core ring -- warm orange, softer glow
RING_CORE_HUE  = 31;  RING_CORE_ALPHA  = 59

# Interlocked star -- warm amber
STAR_HUE = 34;  STAR_SAT = 96;  STAR_ALPHA = 67

# Spark hue range -- fire palette: deep orange to golden amber
SPARK_HUE_MIN = 24
SPARK_HUE_MAX = 47


# ===============================================================
#  SECTION 5 -- SPARK BEHAVIOR
# ===============================================================
SPARK_COUNT           = 300   # total particles alive at any moment
SPARK_SPEED_MIN       = 1.5   # minimum outward speed (px / frame)
SPARK_SPEED_MAX       = 4.0   # maximum outward speed (px / frame)
SPARK_SIZE_MIN        = 2     # minimum dot diameter (px)
SPARK_SIZE_MAX        = 5     # maximum dot diameter (px)
SPARK_BIRTH_NEAR      = 40    # minimum birth radius (px from center)
SPARK_BIRTH_FAR       = 100   # maximum birth radius (px from center)
SPARK_STAGGER_MAX     = 250   # initial stagger radius
SPARK_FADE_OFFSET_MAX = 40    # random fade delay
SPARK_DRIFT_MAX       = 0.03  # maximum angular drift per frame
SPARK_MAX_RADIUS      = 300   # spark resets when it reaches this radius


# ===============================================================
#  GLOBAL STATE
# ===============================================================
sparks        = []    # list holding all Spark instances
angle_mandala = 0.0   # current mandala rotation in radians
angle_star    = 0.0   # current star rotation in radians


# setup()
# Runs once when the sketch starts.
# Creates the canvas, sets the color mode, fills the spark list.
def setup():
    size(CANVAS_W, CANVAS_H)

    # colorMode(HSB, hueMax, satMax, briMax, alphaMax)
    # HSB makes fire colors easy to express:
    #   "burnt orange with 71% opacity" = stroke(33, 100, 100, 71)
    colorMode(HSB, 360, 100, 100, 100)
    frameRate(60)

    for i in range(SPARK_COUNT):
        sparks.append(Spark())


# draw()
# Runs 60 times per second. Clears and repaints the canvas each frame.
#
# Drawing order -- painter's algorithm (later = on top):
#   1. background()    -- wipes the previous frame; everything after is fresh
#   2. Sparks          -- fire particles drawn behind the rings
#   3. draw_mandala()  -- concentric rings + orbital sigils, rotating clockwise
#   4. draw_star()     -- interlocked star geometry, counter-rotating in front
#
# Python note: 'global' is required to modify module-level angle variables.
def draw():
    global angle_mandala, angle_star

    # 1. Background -- wipe the previous frame clean
    background(BG_HUE, BG_SAT, BG_BRI)

    # Move the origin to the center of the canvas
    translate(width / 2, height / 2)

    # 2. Sparks -- update position each frame, then draw (behind the rings)
    for s in sparks:
        s.update()
        s.display()

    # 3. Mandala rings -- pushMatrix saves state, rotate, draw, popMatrix restores
    #    Python Processing uses pushMatrix()/popMatrix() where JS uses push()/pop()
    pushMatrix()
    rotate(angle_mandala)
    draw_mandala()
    popMatrix()
    angle_mandala += MANDALA_SPEED

    # 4. Star -- separate pushMatrix/popMatrix isolates its counter-rotation
    pushMatrix()
    rotate(angle_star)
    draw_star()
    popMatrix()
    angle_star += STAR_SPEED


# draw_mandala()
# Draws three concentric rings and eight evenly-spaced orbital sigils.
#
# Coordinate context: called inside pushMatrix()/rotate()/popMatrix() from draw(),
# so the origin is the canvas center and the whole mandala is already
# rotated by angle_mandala before this function runs.
#
# Uses: RING_* constants for geometry and color.
#       SATELLITE_* constants for the orbital sigil positions.
def draw_mandala():
    noFill()

    # Outer thin boundary ring
    stroke(RING_OUTER_HUE, 100, 100, RING_OUTER_ALPHA)
    strokeWeight(3)
    ellipse(0, 0, RING_OUTER_RADIUS * 2, RING_OUTER_RADIUS * 2)

    # Thick glowing main ring
    stroke(RING_MAIN_HUE, 100, 100, RING_MAIN_ALPHA)
    strokeWeight(6)
    ellipse(0, 0, RING_MAIN_RADIUS * 2, RING_MAIN_RADIUS * 2)

    # Eight satellite sigils -- polar coordinates: x = r*cos(a), y = r*sin(a)
    stroke(RING_SAT_HUE, RING_SAT_SAT, 100, RING_SAT_ALPHA)
    strokeWeight(2)
    for i in range(SATELLITE_COUNT):
        orbit_angle = (TWO_PI / SATELLITE_COUNT) * i  # evenly divide the circle
        pushMatrix()
        rotate(orbit_angle)
        # After rotate(), (SATELLITE_ORBIT_RADIUS, 0) lands on the orbit ring
        ellipse(SATELLITE_ORBIT_RADIUS, 0, SATELLITE_OUTER_SIZE, SATELLITE_OUTER_SIZE)
        ellipse(SATELLITE_ORBIT_RADIUS, 0, SATELLITE_INNER_SIZE, SATELLITE_INNER_SIZE)
        popMatrix()

    # Small inner core ring
    stroke(RING_CORE_HUE, 100, 100, RING_CORE_ALPHA)
    strokeWeight(2)
    ellipse(0, 0, RING_CORE_RADIUS * 2, RING_CORE_RADIUS * 2)


# draw_star()
# Draws two overlapping squares rotated 45 degrees apart -- an eight-pointed star.
#
# Coordinate context: called inside pushMatrix()/rotate()/popMatrix() from draw().
# Uses: STAR_SIZE, STAR_HUE, STAR_SAT, STAR_ALPHA.
def draw_star():
    noFill()
    stroke(STAR_HUE, STAR_SAT, 100, STAR_ALPHA)
    strokeWeight(2.5)
    rectMode(CENTER)  # rect() measures from center, not top-left corner

    pushMatrix()
    rect(0, 0, STAR_SIZE, STAR_SIZE)   # first square -- axis-aligned
    rotate(QUARTER_PI)                  # QUARTER_PI = pi/4 = 45 degrees
    rect(0, 0, STAR_SIZE, STAR_SIZE)   # second square -- rotated 45 degrees
    popMatrix()


# class Spark
#
# A single fire particle that bursts outward from the canvas center.
#
# Lifecycle (one loop per frame):
#   Born near center -> travels outward -> fades as it goes -> resets at edge
#
# Design note on reset():
#   reset() is called by __init__ for initial setup AND by update()
#   when the spark expires.  Keeping birth logic in one place means there
#   is exactly one function to change if the behavior needs to be adjusted.
#
# Python note: fields are dynamic -- no declaration needed before use.
#   snake_case is used throughout (angle, fade_offset vs JS fadeOffset).
class Spark(object):

    def __init__(self):
        self.reset()
        # Stagger starting radii so sparks fill the screen immediately on frame 1
        self.radius = random(SPARK_BIRTH_NEAR, SPARK_STAGGER_MAX)

    # reset() -- reinitialise all properties to a fresh birth state.
    #   radius      current distance from center (will grow each frame)
    #   angle       travel direction (radians, random full circle)
    #   speed       outward velocity (pixels per frame)
    #   size        dot diameter (pixels)
    #   fade_offset random delay before fading begins
    def reset(self):
        self.radius      = random(SPARK_BIRTH_NEAR, SPARK_BIRTH_FAR)
        self.angle       = random(TWO_PI)
        self.speed       = random(SPARK_SPEED_MIN, SPARK_SPEED_MAX)
        self.size        = random(SPARK_SIZE_MIN,  SPARK_SIZE_MAX)
        self.fade_offset = random(0, SPARK_FADE_OFFSET_MAX)

    # update() -- advance the spark one frame outward with a slight spiral drift.
    #   Resets when radius reaches SPARK_MAX_RADIUS (lifecycle ends, spark is reborn).
    def update(self):
        self.radius += self.speed
        self.angle  += random(-0.01, SPARK_DRIFT_MAX)
        if self.radius > SPARK_MAX_RADIUS:
            self.reset()

    # display() -- draw this spark as a dot at its current polar position.
    #
    #   Fade formula:
    #     map() converts the current radius to a life ratio between 1.0 and 0.0.
    #     Multiply by 100 to scale to HSB alpha (0-100).
    #     Subtract fade_offset so sparks fade at different radii, not all at once.
    #
    #   Position (polar to Cartesian):
    #     x = radius * cos(angle)
    #     y = radius * sin(angle)
    def display(self):
        life_ratio    = map(self.radius, SPARK_BIRTH_NEAR, SPARK_MAX_RADIUS, 1.0, 0.0)
        current_alpha = (life_ratio * 100) - self.fade_offset

        if current_alpha > 0:
            x = self.radius * cos(self.angle)
            y = self.radius * sin(self.angle)
            noStroke()
            fill(random(SPARK_HUE_MIN, SPARK_HUE_MAX), 100, 100, current_alpha)
            ellipse(x, y, self.size, self.size)
