class_name Block extends AnimatableBody2D ## One arc-shaped segment of a ring around the core. Its [enum Kind] decides how it looks, ## how many hits it takes and what it does to the ball. ## Emitted when a TNT segment breaks; the rings turn it into an explosion. signal detonated(block: Block) enum Kind { NORMAL, INDESTRUCTIBLE, WATER, ICE, TNT, RUBBER, GHOST, MOSS, MAGNET } ## What [method strike] did, so the game can pick a sound. enum Hit { BROKE, DAMAGED, BLOCKED } const GAP := 4.0 # px between neighbouring tiles const CORNER := 4.0 # corner rounding radius in px const BREAK_SECONDS := 0.15 # fade-out time, after a short flash ## Per kind: first level it can appear, hits to break (0 = unbreakable) and flat colour. ## Normal segments get a random blue-green colour instead. const KINDS := { Kind.NORMAL: { unlock = 0, hp = 1, color = Params.COLOR_BLUE }, Kind.INDESTRUCTIBLE: { unlock = 0, hp = 0, color = Params.COLOR_ORANGE }, Kind.WATER: { unlock = 50, hp = 3, color = Color("#3a7bff") }, Kind.ICE: { unlock = 100, hp = 2, color = Color("#d6f3ff") }, Kind.TNT: { unlock = 150, hp = 1, color = Params.COLOR_PINK }, Kind.RUBBER: { unlock = 200, hp = 2, color = Color("#ae81ff") }, Kind.GHOST: { unlock = 250, hp = 1, color = Color("#c8c3d8") }, Kind.MOSS: { unlock = 300, hp = 1, color = Params.COLOR_GREEN }, Kind.MAGNET: { unlock = 350, hp = 2, color = Color("#9aa4ad") }, } const WATER_STICK_SECONDS := 0.35 const WATER_SLOWDOWN := 0.85 const ICE_SPEEDUP := 1.08 const RUBBER_SPEEDUP := 1.15 const GHOST_SOLID_SECONDS := 2.0 const GHOST_PHASED_SECONDS := 1.2 const MOSS_REGROW_SECONDS := 8.0 # moss grows back once, this long after breaking const MAGNET_RADIUS := 150.0 const MAGNET_PULL := 700.0 # px/s² at the segment, fading to 0 at MAGNET_RADIUS var kind := Kind.NORMAL var hp := 1 var color := Params.COLOR_BLUE var _inner := 0.0 var _outer := 0.0 var _begin := 0.0 var _end := 0.0 var _polygon := PackedVector2Array() var _collision := CollisionPolygon2D.new() var _cracks := PackedVector2Array() var _ghost_clock := 0.0 var _regrown := false ## 0 to 1: how far the tile is washed towards white while breaking. var _flash := 0.0: set(value): _flash = value queue_redraw() func _init(inner: float, outer: float, begin: float, end: float, segment_kind: Kind) -> void: sync_to_physics = false collision_layer = Params.LAYER_BLOCKS collision_mask = 0 _inner = inner _outer = outer _begin = begin _end = end add_child(_collision) set_kind(segment_kind) _ghost_clock = randf() * (GHOST_SOLID_SECONDS + GHOST_PHASED_SECONDS) func set_kind(value: Kind) -> void: kind = value hp = KINDS[kind].hp if kind == Kind.NORMAL: color = Color8( int(102 * (randf() / 5.0 + 0.5)), int(217 * (randf() / 5.0 + 0.5)), int(239 * (randf() / 5.0 + 0.5))) else: color = KINDS[kind].color _rebuild_shape() set_physics_process(kind == Kind.GHOST or kind == Kind.MAGNET) func is_breakable() -> bool: return hp > 0 ## Centre of the segment in global coordinates. func center() -> Vector2: var middle := (_begin + _end) / 2.0 return to_global(Vector2(sin(middle), cos(middle)) * (_inner + _outer) / 2.0) ## A ball hit the segment. Applies the segment's effect on the ball and takes a hit. func strike(ball: Ball) -> Hit: if not is_breakable(): if ball.superball: ball.superball = false destroy() return Hit.BROKE return Hit.BLOCKED if ball.fire: # Fire burns straight through whatever the segment is. hp = 1 return damage() match kind: Kind.ICE: ball.velocity *= ICE_SPEEDUP Kind.RUBBER: ball.velocity *= RUBBER_SPEEDUP var result := damage() if kind == Kind.WATER and result == Hit.DAMAGED: ball.stick_to(self, WATER_STICK_SECONDS, WATER_SLOWDOWN) return result ## Takes one hit from anything (ball, laser, explosion). Unbreakable segments ignore it. func damage() -> Hit: if not is_breakable() or collision_layer == 0: return Hit.BLOCKED hp -= 1 if hp > 0: _rebuild_shape() return Hit.DAMAGED destroy() return Hit.BROKE ## Flashes and fades out. Stops colliding right away. Moss comes back once, after a while. func destroy() -> void: collision_layer = 0 if kind == Kind.TNT: detonated.emit(self) var tween := create_tween() tween.tween_property(self, "_flash", 0.8, BREAK_SECONDS / 3.0) tween.tween_property(self, "modulate:a", 0.0, BREAK_SECONDS) tween.parallel().tween_property(self, "scale", Vector2.ONE * 1.04, BREAK_SECONDS) if kind == Kind.MOSS and not _regrown: tween.tween_interval(MOSS_REGROW_SECONDS) tween.tween_callback(_regrow) else: tween.tween_callback(queue_free) func _regrow() -> void: _regrown = true hp = KINDS[kind].hp _flash = 0.0 scale = Vector2.ONE * 0.6 var tween := create_tween().set_parallel() tween.tween_property(self, "modulate:a", 1.0, 0.4) tween.tween_property(self, "scale", Vector2.ONE, 0.4) tween.chain().tween_callback(set.bind("collision_layer", Params.LAYER_BLOCKS)) func _physics_process(delta: float) -> void: if kind == Kind.GHOST and hp > 0: _ghost_clock = fmod(_ghost_clock + delta, GHOST_SOLID_SECONDS + GHOST_PHASED_SECONDS) var solid := _ghost_clock < GHOST_SOLID_SECONDS collision_layer = Params.LAYER_BLOCKS if solid else 0 modulate.a = 1.0 if solid else 0.2 elif kind == Kind.MAGNET and collision_layer != 0: var here := center() for ball: Ball in get_tree().get_nodes_in_group("balls"): var offset := here - ball.global_position var dist := offset.length() if dist > 0.0 and dist < MAGNET_RADIUS: ball.velocity += offset / dist * MAGNET_PULL * (1.0 - dist / MAGNET_RADIUS) * delta ## Recomputes the drawn and collision outline. Water drains from the outside in as it is hit; ## ice gets cracks; other multi-hit segments darken. func _rebuild_shape() -> void: var outer := _outer var max_hp: int = KINDS[kind].hp if kind == Kind.WATER and hp < max_hp: outer = _inner + (_outer - _inner) * float(hp) / max_hp _polygon = _round_corners(_sector(_inner + GAP / 2.0, outer - GAP / 2.0, _begin, _end, GAP / 2.0)) # Very short segments can leave a sliver too thin to split into convex pieces; skip its # collision rather than let Godot fail on it. var valid := not Geometry2D.decompose_polygon_in_convex(_polygon).is_empty() _collision.set_deferred("polygon", _polygon if valid else PackedVector2Array()) _collision.set_deferred("disabled", not valid) _cracks = PackedVector2Array() if kind == Kind.ICE and hp < max_hp: _cracks = _crack_lines() queue_redraw() func _crack_lines() -> PackedVector2Array: var lines := PackedVector2Array() var middle := (_begin + _end) / 2.0 var radius := (_inner + _outer) / 2.0 var start := Vector2(sin(middle), cos(middle)) * radius for i in 3: var angle := middle + randf_range(-0.6, 0.6) * (_end - _begin) / 2.0 var r := lerpf(_inner + GAP, _outer - GAP, randf()) lines.append(start) lines.append(Vector2(sin(angle), cos(angle)) * r) return lines func _draw() -> void: if _polygon.size() < 3: return # segment too short to show at all var fill := color if hp > 0 and hp < KINDS[kind].hp and kind != Kind.WATER and kind != Kind.ICE: fill = fill.darkened(0.25) fill = fill.lerp(Color.WHITE, _flash) draw_colored_polygon(_polygon, fill) # Polygons have no antialiasing of their own; a smooth outline softens the edge. var outline := _polygon.duplicate() outline.append(_polygon[0]) draw_polyline(outline, fill, 1.0, true) if not _cracks.is_empty(): draw_multiline(_cracks, Color(0.2, 0.35, 0.45, 0.9), 1.5, true) ## Ring segment between two radii. [param gap] trims each side by that many pixels, ## so the space between neighbours has the same width at every radius. An edge too short ## to trim collapses to a point, turning the segment into a wedge. static func _sector(inner: float, outer: float, begin: float, end: float, gap: float) -> PackedVector2Array: var steps := maxi(2, int((end - begin) * sqrt(outer) / 1.5)) return _trimmed_arc(outer, begin, end, gap, steps) + _trimmed_arc(inner, end, begin, -gap, steps) static func _trimmed_arc(radius: float, from: float, to: float, gap: float, steps: int) -> PackedVector2Array: var trim := gap / radius if (to - from - 2.0 * trim) * signf(to - from) <= 0.0: var middle := (from + to) / 2.0 return PackedVector2Array([Vector2(sin(middle), cos(middle)) * radius]) return _arc(radius, from + trim, to - trim, steps) ## Shrinks then regrows the polygon with round joins, which rounds its corners. static func _round_corners(polygon: PackedVector2Array) -> PackedVector2Array: var shrunk := Geometry2D.offset_polygon(polygon, -CORNER) if shrunk.is_empty(): return polygon var rounded := Geometry2D.offset_polygon(shrunk[0], CORNER, Geometry2D.JOIN_ROUND) return polygon if rounded.is_empty() else _without_duplicates(rounded[0]) ## offset_polygon can emit repeated points; zero-length edges break convex decomposition. static func _without_duplicates(polygon: PackedVector2Array) -> PackedVector2Array: var result := PackedVector2Array() for point in polygon: if result.is_empty() or not point.is_equal_approx(result[-1]): result.append(point) if result.size() > 1 and result[0].is_equal_approx(result[-1]): result.remove_at(result.size() - 1) return result static func _arc(radius: float, from: float, to: float, steps: int) -> PackedVector2Array: var points := PackedVector2Array() for i in steps + 1: var angle := lerpf(from, to, float(i) / steps) points.append(Vector2(sin(angle), cos(angle)) * radius) return points