Add Metalstorm-style cursor aim

Virtual stick cursor steered through the gun reticle, server-side virtual pilot
using measured control responses, client cursor/camera handling with look-ahead,
free look and scroll zoom.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
This commit is contained in:
Claude committed 2026-11-11 18:29:13 +01:00
1 parent d376a6b9fa
commit 8bb2cfd859
17 files changed
+1037 -38

No files matched your search

@@ -0,0 +1,34 @@
package dev.lumareth.improvedAerodynamics.client.aim;
import com.mojang.blaze3d.platform.InputConstants;
import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics;
import net.minecraft.client.KeyMapping;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
import net.minecraftforge.client.settings.KeyConflictContext;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import org.lwjgl.glfw.GLFW;
/** Keys of cursor aim. The free look key is not shift: in a seat, shift gets the player out of it. */
public final class AimKeys {
public static final KeyMapping TOGGLE = new KeyMapping("key.improved_aerodynamics.toggle_cursor_aim",
KeyConflictContext.IN_GAME, InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_K, "key.categories.improved_aerodynamics");
public static final KeyMapping FREE_LOOK = new KeyMapping("key.improved_aerodynamics.cursor_aim_free_look",
KeyConflictContext.IN_GAME, InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_LEFT_ALT, "key.categories.improved_aerodynamics");
private AimKeys() {
}
@Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.MOD)
public static final class Registration {
private Registration() {
}
@SubscribeEvent
public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) {
event.register(TOGGLE);
event.register(FREE_LOOK);
}
}
}
@@ -0,0 +1,15 @@
package dev.lumareth.improvedAerodynamics.client.aim;
import net.mcreator.valkyrienwarium.entity.SeatEntityEntity;
import net.minecraft.world.entity.Entity;
/** Everything that touches Warium VS classes, so nothing else loads them when Warium is missing. */
final class AimSeat {
private AimSeat() {
}
static Entity seatOf(Entity rider) {
Entity root = rider.getRootVehicle();
return root instanceof SeatEntityEntity ? root : null;
}
}
@@ -0,0 +1,444 @@
package dev.lumareth.improvedAerodynamics.client.aim;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.BufferUploader;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.blaze3d.vertex.Tesselator;
import com.mojang.blaze3d.vertex.VertexFormat;
import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics;
import dev.lumareth.improvedAerodynamics.client.pipper.GunPipperRenderer;
import dev.lumareth.improvedAerodynamics.flight.AimBasis;
import dev.lumareth.improvedAerodynamics.network.AimPacket;
import dev.lumareth.improvedAerodynamics.network.Network;
import net.minecraft.client.Camera;
import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.InputEvent;
import net.minecraftforge.client.event.RenderLevelStageEvent;
import net.minecraftforge.client.event.ViewportEvent;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.ModList;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.util.ObfuscationReflectionHelper;
import org.joml.Matrix4f;
import org.joml.Quaterniondc;
import org.joml.Vector3d;
import org.joml.Vector3f;
import org.valkyrienskies.core.api.ships.ClientShip;
import org.valkyrienskies.core.api.ships.Ship;
import org.valkyrienskies.mod.common.VSGameUtilsKt;
import java.lang.reflect.Method;
/**
* Cursor aim, the client side. While it is on:
* <ul>
* <li>the mouse moves a cursor instead of the camera. The cursor is a direction relative to the gun reticle (the
* craft's boresight), at most {@code maxCursorAngleDegrees} away from it, and springs back to it when the mouse
* rests. It is sent to the server every tick, where a virtual pilot flies the craft by it;</li>
* <li>the camera is a chase view, looking along the reticle, or, with look ahead, towards the cursor;</li>
* <li>holding the free look key moves the camera instead of the cursor, it eases back when released;</li>
* <li>the mouse wheel changes how far the camera is behind the craft.</li>
* </ul>
* The camera is driven through the rotation of the player, which for a rider of a Valkyrien Skies ship is relative to
* the ship, so the camera follows the ship's attitude like Valkyrien Skies does for any mounted player. Mouse
* movement shows up as a change of that rotation, which is taken as the movement of the cursor and put back.
*/
@Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT)
public final class CursorAimClient {
private static final int CURSOR_COLOR = 0xFFFFE040;
private static final int RETICLE_COLOR = 0xFFFFFFFF;
private static final float MARKER_DISTANCE = 2.0F;
private static final double LOOK_AHEAD_RATE = 6.0;
private static final double FREE_LOOK_RETURN_RATE = 10.0;
private static boolean active;
// Cursor as components along the craft's forward (gun reticle), right and up axes
private static double cf = 1.0;
private static double cr;
private static double cu;
// Camera direction in ship space and the free look offset on top of it, degrees
private static double[] cameraDirection;
private static double offsetYaw;
private static double offsetPitch;
// Rotation the player was given last frame, to see what the mouse did since
private static boolean haveLast;
private static float lastYaw;
private static float lastPitch;
private static long lastNanos;
private static double zoom;
private static CameraType previousCamera;
private static Method cameraMove;
private CursorAimClient() {
}
// ---- Toggling and the packet to the server ----
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase != TickEvent.Phase.END) {
return;
}
Minecraft minecraft = Minecraft.getInstance();
LocalPlayer player = minecraft.player;
while (AimKeys.TOGGLE.consumeClick()) {
if (active) {
deactivate(minecraft);
} else if (player != null) {
activate(minecraft, player);
}
}
if (!active) {
return;
}
if (player == null || !seated(player)) {
deactivate(minecraft);
return;
}
send(minecraft, true);
}
private static boolean seated(LocalPlayer player) {
return ModList.get().isLoaded("valkyrien_warium") && AimSeat.seatOf(player) != null;
}
private static boolean serverHasMod(Minecraft minecraft) {
return minecraft.getConnection() != null && Network.CHANNEL.isRemotePresent(minecraft.getConnection().getConnection());
}
private static void activate(Minecraft minecraft, LocalPlayer player) {
if (!seated(player)) {
player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.no_seat"), true);
return;
}
if (!serverHasMod(minecraft)) {
player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.no_server"), true);
return;
}
active = true;
cf = 1.0;
cr = 0.0;
cu = 0.0;
cameraDirection = null;
offsetYaw = 0.0;
offsetPitch = 0.0;
haveLast = false;
lastNanos = System.nanoTime();
previousCamera = minecraft.options.getCameraType();
minecraft.options.setCameraType(CameraType.THIRD_PERSON_BACK);
player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.on"), true);
}
private static void deactivate(Minecraft minecraft) {
if (!active) {
return;
}
active = false;
send(minecraft, false);
if (previousCamera != null) {
minecraft.options.setCameraType(previousCamera);
previousCamera = null;
}
if (minecraft.player != null) {
minecraft.player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.off"), true);
}
}
private static void send(Minecraft minecraft, boolean on) {
if (serverHasMod(minecraft)) {
Network.CHANNEL.sendToServer(new AimPacket(on, (float) cf, (float) cr, (float) cu));
}
}
// ---- Mouse and camera, once per frame before the world is rendered ----
@SubscribeEvent
public static void onRenderTick(TickEvent.RenderTickEvent event) {
if (!active || event.phase != TickEvent.Phase.START) {
return;
}
Minecraft minecraft = Minecraft.getInstance();
LocalPlayer player = minecraft.player;
ClientLevel level = minecraft.level;
if (player == null || level == null || !seated(player)) {
return;
}
Entity seat = AimSeat.seatOf(player);
AimBasis basis = AimBasis.of(level, seat.blockPosition());
if (basis == null) {
return;
}
if (minecraft.options.getCameraType() != CameraType.THIRD_PERSON_BACK) {
minecraft.options.setCameraType(CameraType.THIRD_PERSON_BACK);
}
long now = System.nanoTime();
double dt = Mth.clamp((now - lastNanos) / 1.0E9, 0.0, 0.1);
lastNanos = now;
// What the mouse did: the player's rotation moved away from what it was set to
double deltaYaw = 0.0;
double deltaPitch = 0.0;
if (haveLast && minecraft.screen == null) {
deltaYaw = Mth.wrapDegrees(player.getYRot() - lastYaw);
deltaPitch = player.getXRot() - lastPitch;
if (Math.abs(deltaYaw) > 90.0 || Math.abs(deltaPitch) > 90.0) {
// Not the mouse, something reset the rotation
deltaYaw = 0.0;
deltaPitch = 0.0;
}
}
double sensitivity = CursorAimClientConfig.MOUSE_SENSITIVITY.get();
boolean freeLook = minecraft.screen == null && AimKeys.FREE_LOOK.isDown();
double spring = CursorAimClientConfig.SPRING_BACK_SECONDS.get();
if (spring > 0.0) {
shrinkCursor(Math.exp(-dt / spring));
}
if (freeLook) {
offsetYaw += deltaYaw * sensitivity;
offsetPitch = Mth.clamp(offsetPitch - deltaPitch * sensitivity, -89.0, 89.0);
} else {
moveCursor(Math.toRadians(deltaYaw * sensitivity), Math.toRadians(-deltaPitch * sensitivity));
double decay = Math.exp(-dt * FREE_LOOK_RETURN_RATE);
offsetYaw *= decay;
offsetPitch *= decay;
}
// Camera: along the reticle, or turned towards the cursor by the look ahead setting
double lookAhead = CursorAimClientConfig.LOOK_AHEAD.get();
double[] forward = basis.forward();
if (cameraDirection == null || lookAhead <= 0.0) {
cameraDirection = forward.clone();
} else {
double[] target = basis.toShip(cf, cr, cu);
cameraDirection = turnTowards(cameraDirection, target, 1.0 - Math.exp(-lookAhead * LOOK_AHEAD_RATE * dt));
}
double[] direction = rotate(cameraDirection, basis.up(), -Math.toRadians(offsetYaw));
direction = rotate(direction, rotate(basis.right(), basis.up(), -Math.toRadians(offsetYaw)), Math.toRadians(offsetPitch));
// Valkyrien Skies reads the view of a mounted player in ship space: yaw 0 looks along +Z
float yaw = (float) Math.toDegrees(Math.atan2(-direction[0], direction[2]));
float pitch = (float) Math.toDegrees(Math.atan2(-direction[1], Math.hypot(direction[0], direction[2])));
player.setYRot(yaw);
player.setXRot(pitch);
player.yRotO = yaw;
player.xRotO = pitch;
player.yHeadRot = yaw;
player.yHeadRotO = yaw;
lastYaw = yaw;
lastPitch = pitch;
haveLast = true;
}
/** Moves the cursor like a look direction: yaw towards the right, pitch up, radians. */
private static void moveCursor(double yaw, double pitch) {
double nf = cf * Math.cos(yaw) - cr * Math.sin(yaw);
double nr = cf * Math.sin(yaw) + cr * Math.cos(yaw);
cf = nf;
cr = nr;
nf = cf * Math.cos(pitch) - cu * Math.sin(pitch);
double nu = cf * Math.sin(pitch) + cu * Math.cos(pitch);
cf = nf;
cu = nu;
normalizeCursor();
limitCursor();
}
/** Brings the cursor back towards the reticle along the shortest way, keeping only this fraction of its angle. */
private static void shrinkCursor(double keep) {
double angle = Math.acos(Mth.clamp(cf, -1.0, 1.0));
double lateral = Math.sqrt(cr * cr + cu * cu);
if (angle < 1.0E-6 || lateral < 1.0E-9) {
cf = 1.0;
cr = 0.0;
cu = 0.0;
return;
}
double newAngle = angle * keep;
double scale = Math.sin(newAngle) / lateral;
cf = Math.cos(newAngle);
cr *= scale;
cu *= scale;
}
private static void normalizeCursor() {
double length = Math.sqrt(cf * cf + cr * cr + cu * cu);
if (!(length > 1.0E-9)) {
cf = 1.0;
cr = 0.0;
cu = 0.0;
return;
}
cf /= length;
cr /= length;
cu /= length;
}
private static void limitCursor() {
double limit = Math.toRadians(CursorAimClientConfig.MAX_CURSOR_ANGLE_DEGREES.get());
double angle = Math.acos(Mth.clamp(cf, -1.0, 1.0));
if (angle <= limit) {
return;
}
double lateral = Math.sqrt(cr * cr + cu * cu);
if (lateral < 1.0E-9) {
return;
}
double scale = Math.sin(limit) / lateral;
cf = Math.cos(limit);
cr *= scale;
cu *= scale;
}
// ---- Mouse wheel and camera distance ----
@SubscribeEvent
public static void onScroll(InputEvent.MouseScrollingEvent event) {
Minecraft minecraft = Minecraft.getInstance();
if (!active || minecraft.screen != null) {
return;
}
zoom = Mth.clamp(zoom - event.getScrollDelta() * CursorAimClientConfig.ZOOM_STEP.get(), -3.0, CursorAimClientConfig.ZOOM_MAX.get());
event.setCanceled(true);
}
@SubscribeEvent
public static void onCameraAngles(ViewportEvent.ComputeCameraAngles event) {
if (!active || Math.abs(zoom) < 1.0E-3) {
return;
}
try {
if (cameraMove == null) {
// Camera.move(double, double, double), moves along the camera's own axes
cameraMove = ObfuscationReflectionHelper.findMethod(Camera.class, "m_90568_", double.class, double.class, double.class);
}
cameraMove.invoke(event.getCamera(), -zoom, 0.0, 0.0);
} catch (ReflectiveOperationException | RuntimeException exception) {
ImprovedAerodynamics.LOGGER.warn("Cursor aim camera zoom is not available", exception);
zoom = 0.0;
}
}
// ---- Drawing the cursor ----
@SubscribeEvent
public static void onRenderLevel(RenderLevelStageEvent event) {
if (!active || event.getStage() != RenderLevelStageEvent.Stage.AFTER_PARTICLES) {
return;
}
Minecraft minecraft = Minecraft.getInstance();
ClientLevel level = minecraft.level;
LocalPlayer player = minecraft.player;
if (level == null || player == null || !seated(player)) {
return;
}
Entity seat = AimSeat.seatOf(player);
AimBasis basis = AimBasis.of(level, seat.blockPosition());
Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()));
if (basis == null || ship == null) {
return;
}
Quaterniondc rotation = ship instanceof ClientShip clientShip
? clientShip.getRenderTransform().getShipToWorldRotation() : ship.getTransform().getShipToWorldRotation();
double[] cursorShip = basis.toShip(cf, cr, cu);
Vector3d cursorWorld = rotation.transform(new Vector3d(cursorShip[0], cursorShip[1], cursorShip[2]));
double[] forward = basis.forward();
Vector3d reticleWorld = rotation.transform(new Vector3d(forward[0], forward[1], forward[2]));
Camera camera = event.getCamera();
Matrix4f matrix = event.getPoseStack().last().pose();
BufferBuilder builder = Tesselator.getInstance().getBuilder();
RenderSystem.disableDepthTest();
RenderSystem.enableBlend();
RenderSystem.defaultBlendFunc();
RenderSystem.disableCull();
RenderSystem.setShader(GameRenderer::getPositionColorShader);
builder.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
float size = (float) (CursorAimClientConfig.MARKER_SIZE.get() * MARKER_DISTANCE);
if (CursorAimClientConfig.SHOW_RETICLE.get()) {
plus(builder, matrix, camera, reticleWorld, size * 0.35F, RETICLE_COLOR);
}
ring(builder, matrix, camera, cursorWorld, size, CURSOR_COLOR);
BufferUploader.drawWithShader(builder.end());
RenderSystem.enableCull();
RenderSystem.enableDepthTest();
RenderSystem.disableBlend();
}
private static Vector3f screenPoint(Camera camera, Vector3d direction) {
Vector3f center = new Vector3f((float) direction.x, (float) direction.y, (float) direction.z);
if (center.lengthSquared() < 1.0E-9F) {
return null;
}
return center.normalize().mul(MARKER_DISTANCE);
}
private static void ring(BufferBuilder builder, Matrix4f matrix, Camera camera, Vector3d direction, float radius, int color) {
Vector3f center = screenPoint(camera, direction);
if (center == null) {
return;
}
Vector3f left = new Vector3f(camera.getLeftVector());
Vector3f up = new Vector3f(camera.getUpVector());
float thickness = radius * 0.12F;
GunPipperRenderer.ring(builder, matrix, center, left, up, radius, thickness, color);
GunPipperRenderer.quad(builder, matrix, center, left, up, thickness * 1.4F, thickness * 1.4F, color);
}
private static void plus(BufferBuilder builder, Matrix4f matrix, Camera camera, Vector3d direction, float arm, int color) {
Vector3f center = screenPoint(camera, direction);
if (center == null) {
return;
}
Vector3f left = new Vector3f(camera.getLeftVector());
Vector3f up = new Vector3f(camera.getUpVector());
float thickness = arm * 0.14F;
GunPipperRenderer.quad(builder, matrix, center, left, up, arm, thickness, color);
GunPipperRenderer.quad(builder, matrix, center, left, up, thickness, arm, color);
}
// ---- Vector helpers on arrays of three ----
/** Rotates v about the unit axis by an angle in radians (Rodrigues). */
private static double[] rotate(double[] v, double[] axis, double angle) {
double c = Math.cos(angle);
double s = Math.sin(angle);
double dot = v[0] * axis[0] + v[1] * axis[1] + v[2] * axis[2];
double cx = axis[1] * v[2] - axis[2] * v[1];
double cy = axis[2] * v[0] - axis[0] * v[2];
double cz = axis[0] * v[1] - axis[1] * v[0];
return new double[]{
v[0] * c + cx * s + axis[0] * dot * (1.0 - c),
v[1] * c + cy * s + axis[1] * dot * (1.0 - c),
v[2] * c + cz * s + axis[2] * dot * (1.0 - c)};
}
/** Turns unit vector a towards unit vector b by a fraction of the angle between them. */
private static double[] turnTowards(double[] a, double[] b, double fraction) {
double dot = Mth.clamp(a[0] * b[0] + a[1] * b[1] + a[2] * b[2], -1.0, 1.0);
double cx = a[1] * b[2] - a[2] * b[1];
double cy = a[2] * b[0] - a[0] * b[2];
double cz = a[0] * b[1] - a[1] * b[0];
double sin = Math.sqrt(cx * cx + cy * cy + cz * cz);
if (sin < 1.0E-9) {
return dot > 0.0 ? b.clone() : a.clone();
}
double[] axis = {cx / sin, cy / sin, cz / sin};
return rotate(a, axis, Math.acos(dot) * fraction);
}
}
@@ -0,0 +1,45 @@
package dev.lumareth.improvedAerodynamics.client.aim;
import net.minecraftforge.common.ForgeConfigSpec;
/** Client side settings of cursor aim. Lives in improved_aerodynamics-cursor-aim-client.toml. */
public final class CursorAimClientConfig {
public static final ForgeConfigSpec SPEC;
public static final ForgeConfigSpec.DoubleValue LOOK_AHEAD;
public static final ForgeConfigSpec.DoubleValue MOUSE_SENSITIVITY;
public static final ForgeConfigSpec.DoubleValue SPRING_BACK_SECONDS;
public static final ForgeConfigSpec.DoubleValue MAX_CURSOR_ANGLE_DEGREES;
public static final ForgeConfigSpec.DoubleValue ZOOM_STEP;
public static final ForgeConfigSpec.DoubleValue ZOOM_MAX;
public static final ForgeConfigSpec.DoubleValue MARKER_SIZE;
public static final ForgeConfigSpec.BooleanValue SHOW_RETICLE;
static {
ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder();
builder.comment("Cursor aim, client side").push("cursor_aim");
LOOK_AHEAD = builder.comment("How strongly the camera looks into the direction of the turn. 0 never moves it, 1 (100%) turns it quickly",
"to look where the cursor points. In between it follows more slowly")
.defineInRange("lookAhead", 0.0, 0.0, 1.0);
MOUSE_SENSITIVITY = builder.comment("Multiplier for how far the mouse moves the cursor (or the camera while the free look key is held)")
.defineInRange("mouseSensitivity", 1.0, 0.05, 10.0);
SPRING_BACK_SECONDS = builder.comment("The cursor returns to the gun reticle with this time constant in seconds when you stop moving the mouse.",
"0 makes it stay where you left it")
.defineInRange("springBackSeconds", 1.0, 0.0, 10.0);
MAX_CURSOR_ANGLE_DEGREES = builder.comment("Furthest the cursor can go from the gun reticle, 135 is a 270 degree cone")
.defineInRange("maxCursorAngleDegrees", 135.0, 10.0, 180.0);
ZOOM_STEP = builder.comment("Blocks the camera moves per mouse wheel notch")
.defineInRange("zoomStep", 2.0, 0.1, 20.0);
ZOOM_MAX = builder.comment("Furthest the camera can be pulled back from its normal distance, in blocks")
.defineInRange("zoomMax", 80.0, 0.0, 500.0);
MARKER_SIZE = builder.comment("Radius of the cursor ring on screen")
.defineInRange("markerSize", 0.04, 0.01, 0.3);
SHOW_RETICLE = builder.comment("Draw a small cross where the gun reticle points, the zero of the cursor, so you can see it when the camera looks away")
.define("showReticle", true);
builder.pop();
SPEC = builder.build();
}
private CursorAimClientConfig() {
}
}
@@ -218,7 +218,7 @@ public final class GunPipperRenderer {
return true;
}
private static void ring(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float radius, float thickness, int color) {
public static void ring(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float radius, float thickness, int color) {
final int segments = 32;
float inner = radius - thickness;
float outer = radius + thickness;
@@ -232,7 +232,7 @@ public final class GunPipperRenderer {
}
}
private static void quad(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float halfX, float halfY, int color) {
public static void quad(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float halfX, float halfY, int color) {
vertex(builder, matrix, center, left, up, -halfX, -halfY, color);
vertex(builder, matrix, center, left, up, halfX, -halfY, color);
vertex(builder, matrix, center, left, up, halfX, halfY, color);