extends Node3D ## Run CharacterWorkshop.tscn to combine parts, inspect motion, and save authored profiles. const PRESETS: Array[String] = [ "reference_cat", "fox_rambler", "rabbit_scholar", "badger_baker", "otter_courier", "lop_gardener" ] const PROFILE_DIR := "res://assets/characters/profiles/" var profile: CharacterAppearanceProfile var actor: Node3D var camera: Camera3D var controls: VBoxContainer var ui: CanvasLayer var walk := false var phase := 0.0 var turn := -0.32 var dragging := false var status: Label func _ready() -> void: _build_stage() _build_ui() _select_preset(0) func _process(delta: float) -> void: actor.rotation.y = turn phase += delta * 5.0 var swing := sin(phase) if walk else 0.0 for side in ["Left", "Right"]: var direction := -1.0 if side == "Left" else 1.0 actor.get_node("Leg" + side).rotation.x = swing * direction * 0.44 actor.get_node("Arm" + side).rotation.x = ( -swing * direction * 0.30 * float(actor.get_meta("arm_swing", 1.0)) ) var rest := AnimalAppearance.rest_heights(actor) var bob := absf(sin(phase * 2.0)) * 0.035 if walk else 0.0 actor.get_node("Body").position.y = rest.x + bob actor.get_node("Head").position.y = rest.y + bob actor.get_node("Hair").position.y = rest.z + bob func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: dragging = event.pressed if event is InputEventMouseMotion and dragging: turn += event.relative.x * 0.008 if event is InputEventMouseButton and event.pressed: if event.button_index == MOUSE_BUTTON_WHEEL_UP: camera.size = maxf(2.8, camera.size - 0.2) elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: camera.size = minf(6.0, camera.size + 0.2) func _build_stage() -> void: var environment := WorldEnvironment.new() var env := Environment.new() environment.environment = env env.background_mode = Environment.BG_COLOR env.background_color = Color("e2e4d8") env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_color = Color("d0ddd8") env.ambient_light_energy = 0.38 env.tonemap_mode = Environment.TONE_MAPPER_FILMIC # Calibrated for this preview stage to retain cream fabric highlights on GLES. if RenderingServer.get_current_rendering_method() == "gl_compatibility": env.tonemap_exposure = 0.25 add_child(environment) var light := DirectionalLight3D.new() light.rotation_degrees = Vector3(-38, -32, 0) light.light_color = Color("fff6e8") light.light_energy = 1.1 light.shadow_enabled = true add_child(light) var floor_mesh := MeshInstance3D.new() var plane := PlaneMesh.new() plane.size = Vector2(200, 200) var material := StandardMaterial3D.new() material.albedo_color = Color("d0d6c3") material.roughness = 1.0 plane.material = material floor_mesh.mesh = plane add_child(floor_mesh) actor = load("res://player/PlayerVisual.tscn").instantiate() add_child(actor) actor.set_process(false) actor.get_node("Sword").hide() actor.position.x = 0.80 camera = Camera3D.new() add_child(camera) camera.position = Vector3(0, 2.45, 7) camera.look_at(Vector3(0, 1.36, 0)) camera.projection = Camera3D.PROJECTION_ORTHOGONAL camera.size = 3.9 camera.current = true get_viewport().msaa_3d = Viewport.MSAA_4X func _build_ui() -> void: ui = CanvasLayer.new() add_child(ui) var panel := PanelContainer.new() panel.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE) panel.offset_right = 320 var theme := Theme.new() theme.default_font_size = 15 theme.set_color("font_color", "Label", Color("37433a")) theme.set_color("font_color", "CheckButton", Color("37433a")) theme.set_color("font_pressed_color", "CheckButton", Color("37433a")) theme.set_color("font_hover_color", "CheckButton", Color("37433a")) theme.set_color("font_hover_pressed_color", "CheckButton", Color("37433a")) for type in ["Button", "OptionButton"]: for state in ["normal", "hover", "pressed", "focus"]: var button_style := StyleBoxFlat.new() button_style.bg_color = Color("d6dccd") if state == "hover" else Color("e2e4da") button_style.content_margin_left = 8 button_style.content_margin_right = 12 button_style.content_margin_top = 6 button_style.content_margin_bottom = 6 theme.set_stylebox(state, type, button_style) for state in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color"]: theme.set_color(state, type, Color("37433a")) panel.theme = theme var paper := StyleBoxFlat.new() paper.bg_color = Color("f2f0e7") paper.content_margin_left = 24 paper.content_margin_right = 24 paper.content_margin_top = 24 paper.content_margin_bottom = 20 panel.add_theme_stylebox_override("panel", paper) ui.add_child(panel) var scroll := ScrollContainer.new() scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED panel.add_child(scroll) controls = VBoxContainer.new() controls.size_flags_horizontal = Control.SIZE_EXPAND_FILL controls.add_theme_constant_override("separation", 10) scroll.add_child(controls) func _select_preset(index: int) -> void: profile = load(PROFILE_DIR + PRESETS[index] + ".tres").duplicate() _rebuild_controls(index) _apply() func _rebuild_controls(selected: int) -> void: for child in controls.get_children(): controls.remove_child(child) child.queue_free() _label("THE WARDROBE", 23) _label("Woodland travelers", 16) var presets := OptionButton.new() for preset in PRESETS: presets.add_item(preset.replace("_", " ").capitalize()) presets.selected = selected presets.item_selected.connect(_select_preset) controls.add_child(presets) _select("Species", "species", ["Cat", "Fox", "Rabbit", "Badger", "Otter"]) _slider("Body fullness", "body_fullness", 0.0, 1.0) _slider("Height", "height", 0.80, 1.15) _slider("Head size", "head_size", 0.88, 1.12) _slider("Leg length", "leg_length", 0.85, 1.15) _slider("Ear length", "ear_length", 0.65, 1.20) _select("Ears", "ears", ["Species default", "Cat", "Fox", "Rabbit", "Lop", "Badger", "Otter"]) _select("Clothes", "outfit", ["Vest", "Long coat", "Work tunic"]) _select("Arms", "arm_pose", ["Relaxed", "Holding pack straps"]) _select("Bag", "bag", ["None", "Satchel", "Travel pack"]) _select("Hat", "hat", ["None", "Beret", "Brimmed hat"]) for field in ["scarf", "spectacles"]: var toggle := CheckButton.new() toggle.text = field.capitalize() toggle.button_pressed = profile.get(field) toggle.toggled.connect(func(value: bool) -> void: _change(field, value)) controls.add_child(toggle) for field in ["fur_color", "outfit_color", "scarf_color", "trousers_color"]: var row := HBoxContainer.new() controls.add_child(row) var label := Label.new() label.text = field.trim_suffix("_color").capitalize() label.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(label) var color := ColorPickerButton.new() color.custom_minimum_size = Vector2(72, 28) color.edit_alpha = false color.color = profile.get(field) color.color_changed.connect(func(value: Color) -> void: _change(field, value)) row.add_child(color) var motion := CheckButton.new() motion.text = "Walk preview" motion.button_pressed = walk motion.toggled.connect(func(value: bool) -> void: walk = value) controls.add_child(motion) var save := Button.new() save.text = "Save profile…" save.pressed.connect(_save_profile) controls.add_child(save) status = _label("Drag to turn · scroll to zoom", 13) func _label(text: String, size: int) -> Label: var label := Label.new() label.text = text label.add_theme_font_size_override("font_size", size) controls.add_child(label) return label func _select(title: String, field: String, options: Array) -> void: var row := HBoxContainer.new() controls.add_child(row) var label := Label.new() label.text = title label.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(label) var select := OptionButton.new() for option: String in options: select.add_item(option) select.selected = profile.get(field) select.item_selected.connect(func(value: int) -> void: _change(field, value)) row.add_child(select) func _slider(title: String, field: String, minimum: float, maximum: float) -> void: var label := _label(title + " %.2f" % float(profile.get(field)), 14) var slider := HSlider.new() slider.min_value = minimum slider.max_value = maximum slider.step = 0.01 slider.value = profile.get(field) slider.value_changed.connect( func(value: float) -> void: label.text = title + " %.2f" % value _change(field, value) ) controls.add_child(slider) func _change(field: String, value: Variant) -> void: profile.set(field, value) _apply() func _apply() -> void: AnimalAppearance.apply_to(actor, 0, false, profile) func _save_profile() -> void: var dialog := FileDialog.new() dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE dialog.access = FileDialog.ACCESS_FILESYSTEM dialog.filters = PackedStringArray(["*.tres ; Character appearance"]) dialog.current_dir = ProjectSettings.globalize_path(PROFILE_DIR) dialog.current_file = "custom_traveler.tres" dialog.file_selected.connect( func(path: String) -> void: var error := ResourceSaver.save(profile, path) status.text = "Profile saved." if error == OK else "Could not save profile." dialog.queue_free() ) dialog.canceled.connect(dialog.queue_free) ui.add_child(dialog) dialog.popup_centered(Vector2i(720, 500))