diff --git a/3D/Scenes/Props/control_pad_shoot_3d.tscn b/3D/Scenes/Props/control_pad_shoot_3d.tscn
index 7602b5c3..4eb49b87 100644
--- a/3D/Scenes/Props/control_pad_shoot_3d.tscn
+++ b/3D/Scenes/Props/control_pad_shoot_3d.tscn
@@ -5,10 +5,60 @@
[ext_resource type="AudioStream" uid="uid://bjvklk7qmlivd" path="res://SFX/288963__littlerobotsoundfactory__click_electronic_14.wav" id="3_pmslt"]
[ext_resource type="AudioStream" uid="uid://myr6n2c1u503" path="res://SFX/581602__samsterbirdies__beep-error.mp3" id="4_4smss"]
[ext_resource type="PackedScene" uid="uid://w6jg5rx6d5gp" path="res://3D/BlockbenchModels/ControlPad/Control_Pad_Shoot.gltf" id="5_uhypp"]
+[ext_resource type="Texture2D" uid="uid://b7i8madir447q" path="res://3D/BlockbenchModels/ControlPad/Control_Pad_Shoot_0.png" id="6_pmslt"]
+[ext_resource type="Script" uid="uid://b46e1ft1scvwt" path="res://Scripts/Interactables/FrameAnimator3D.cs" id="6_uok2k"]
[sub_resource type="SphereShape3D" id="SphereShape3D_itd0i"]
radius = 0.868968
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_4smss"]
+resource_name = "material_0"
+transparency = 2
+alpha_scissor_threshold = 0.05
+alpha_antialiasing_mode = 0
+cull_mode = 2
+albedo_texture = ExtResource("6_pmslt")
+texture_filter = 0
+texture_repeat = false
+
+[sub_resource type="Animation" id="Animation_pmslt"]
+resource_name = "Flash"
+length = 0.8
+loop_mode = 1
+tracks/0/type = "value"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("AnimatorManager:CurrentFrame")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"times": PackedFloat32Array(0, 0.20016107, 0.40089402, 0.60219884),
+"transitions": PackedFloat32Array(1, 1, 1, 1),
+"update": 1,
+"values": [0, 1, 2, 3]
+}
+
+[sub_resource type="Animation" id="Animation_4smss"]
+length = 0.001
+tracks/0/type = "value"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("AnimatorManager:CurrentFrame")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"times": PackedFloat32Array(0),
+"transitions": PackedFloat32Array(1),
+"update": 1,
+"values": [0]
+}
+
+[sub_resource type="AnimationLibrary" id="AnimationLibrary_fmo5v"]
+_data = {
+&"Flash": SubResource("Animation_pmslt"),
+&"RESET": SubResource("Animation_4smss")
+}
+
[node name="ControlPad" type="Area3D" unique_id=454991887 groups=["Interactable"]]
collision_layer = 0
collision_mask = 136
@@ -38,3 +88,16 @@ stream = ExtResource("4_4smss")
bus = &"Effects"
[node name="blockbench_export2" parent="." unique_id=920256639 instance=ExtResource("5_uhypp")]
+
+[node name="cylinder" parent="blockbench_export2" index="0"]
+surface_material_override/0 = SubResource("StandardMaterial3D_4smss")
+
+[node name="AnimatorManager" type="Node3D" parent="." unique_id=1796988427 node_paths=PackedStringArray("TargetMesh")]
+script = ExtResource("6_uok2k")
+TargetMesh = NodePath("../blockbench_export2/cylinder")
+
+[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=38747516]
+libraries/ = SubResource("AnimationLibrary_fmo5v")
+autoplay = &"Flash"
+
+[editable path="blockbench_export2"]
diff --git a/Scripts/Interactables/FrameAnimator3D.cs b/Scripts/Interactables/FrameAnimator3D.cs
new file mode 100644
index 00000000..95fe5338
--- /dev/null
+++ b/Scripts/Interactables/FrameAnimator3D.cs
@@ -0,0 +1,94 @@
+using Godot;
+
+namespace Cirno.Scripts.Interactables;
+
+///
+/// Animates a tiled texture by shifting the UV1 vertical offset on a mesh surface material.
+/// Designed for Blockbench-exported meshes whose texture is a vertical sprite sheet
+/// (e.g. 16×64 with 4 frames stacked top-to-bottom).
+///
+/// Usage:
+/// 1. Attach this node as a child of (or sibling to) the MeshInstance3D you want to animate.
+/// 2. Optionally assign in the inspector; if left empty the node
+/// will search its parent for the first MeshInstance3D automatically.
+/// 3. Set to the number of frames in the sprite sheet.
+/// 4. Drive from an AnimationPlayer track — the script handles
+/// the UV offset calculation.
+///
+[Tool]
+public partial class FrameAnimator3D : Node3D
+{
+ ///
+ /// The mesh whose surface_0 material UV offset will be updated.
+ /// If null, the node searches its parent for the first MeshInstance3D on _Ready.
+ ///
+ [Export] public MeshInstance3D TargetMesh { get; set; }
+
+ ///
+ /// Total number of animation frames stacked vertically in the texture.
+ ///
+ [Export] public int FrameCount { get; set; } = 4;
+
+ ///
+ /// Surface index on the mesh to apply the UV offset to.
+ /// Defaults to 0, matching Blockbench's typical single-surface export.
+ ///
+ [Export] public int SurfaceIndex { get; set; } = 0;
+
+ private int _currentFrame;
+
+ ///
+ /// The active frame index (0-based). Set this from an AnimationPlayer track to animate.
+ /// Clamped to [0, - 1].
+ ///
+ [Export]
+ public int CurrentFrame
+ {
+ get => _currentFrame;
+ set
+ {
+ _currentFrame = Mathf.Clamp(value, 0, Mathf.Max(0, FrameCount - 1));
+ ApplyFrame();
+ }
+ }
+
+ public override void _Ready()
+ {
+ // Auto-discover the mesh from the parent if none was assigned in the inspector.
+ if (TargetMesh is null)
+ {
+ TargetMesh = GetParent() as MeshInstance3D
+ ?? GetParentOrNull()?.FindChild("*", recursive: false, owned: false) as MeshInstance3D;
+ }
+
+ ApplyFrame();
+ }
+
+ ///
+ /// Calculates the normalised V offset for and writes it to the
+ /// override material on 's surface.
+ /// Each frame occupies an equal vertical slice: offset = frame / frameCount.
+ ///
+ private void ApplyFrame()
+ {
+ if (TargetMesh is null || FrameCount <= 0) return;
+
+ // Ensure we have an override material to write to so we don't mutate shared resources.
+ if (TargetMesh.GetSurfaceOverrideMaterial(SurfaceIndex) is not StandardMaterial3D mat)
+ {
+ var baseMat = TargetMesh.GetActiveMaterial(SurfaceIndex);
+
+ // Duplicate the base material so sibling nodes keep their own state.
+ mat = baseMat?.Duplicate() as StandardMaterial3D;
+ if (mat is null) return;
+
+ TargetMesh.SetSurfaceOverrideMaterial(SurfaceIndex, mat);
+ }
+
+ // Each frame is a 1/FrameCount slice; V offset moves down one slice per frame.
+ float vOffset = (float)_currentFrame / FrameCount;
+ mat.Uv1Offset = new Vector3(mat.Uv1Offset.X, vOffset, mat.Uv1Offset.Z);
+ }
+}
+
+
diff --git a/Scripts/Interactables/FrameAnimator3D.cs.uid b/Scripts/Interactables/FrameAnimator3D.cs.uid
new file mode 100644
index 00000000..47177a69
--- /dev/null
+++ b/Scripts/Interactables/FrameAnimator3D.cs.uid
@@ -0,0 +1 @@
+uid://b46e1ft1scvwt