add cell-span grid with resize handles, 3d water surface effect, and shader preset system with double-click popup menu

This commit is contained in:
Eric Smith 2026-05-20 16:18:33 -04:00
parent 7cfbf72ca5
commit 9cbb54cc39
3 changed files with 721 additions and 252 deletions

View file

@ -13,8 +13,17 @@ signal module_removed(module: Control)
var columns: int = 0
var rows: int = 0
var _cells: Array = [] # 2D: _cells[row][col] -> PanelContainer
var _module_positions: Dictionary = {} # "col,row" -> Control
# Visual cells — shown as drag guides
var _cells: Array = [] # _cells[row][col] -> PanelContainer
# Occupancy: _grid[row][col] -> Control or null
var _grid: Array = []
# Ordered module list (row-major order for layout)
var _module_list: Array[Control] = []
# Cell styles cached for show/hide
var _cell_styles: Dictionary = {}
# Drag state
var _is_dragging: bool = false
@ -25,43 +34,131 @@ var _drag_mouse_offset: Vector2 = Vector2.ZERO
var _hover_col: int = -1
var _hover_row: int = -1
# Visible cell styles cached per-cell so we can modify them during drag
var _cell_styles: Dictionary = {} # "col,row" -> StyleBoxFlat
# Resize state
var _is_resizing: bool = false
var _resize_module: Control = null
var _resize_edge: int = 0 # bitfield: 1=left, 2=right, 4=top, 8=bottom
var _resize_origin_col: int = -1
var _resize_origin_row: int = -1
var _resize_origin_w: int = 1
var _resize_origin_h: int = 1
var _resize_preview: ColorRect = null # ghost showing new size
# Size of a single cell in pixels (computed during _layout_cells)
var _cell_w: float = 1.0
var _cell_h: float = 1.0
# Resize edge detection threshold (pixels)
const RESIZE_THRESHOLD: float = 10.0
const EDGE_LEFT: int = 1
const EDGE_RIGHT: int = 2
const EDGE_TOP: int = 4
const EDGE_BOTTOM: int = 8
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
resized.connect(_rebuild_grid)
gui_input.connect(_on_gui_input)
mouse_exited.connect(_on_mouse_exited)
_rebuild_grid()
func _on_mouse_exited() -> void:
if _is_dragging:
if _drag_module != null:
_drag_module.modulate = Color(1, 1, 1, 1)
_drop_to_source()
_layout_cells()
_finish_drag()
_show_cells(false)
elif _is_resizing:
_end_resize(Vector2.ZERO)
func _on_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb: InputEventMouseButton = event
if mb.button_index == MOUSE_BUTTON_LEFT and not mb.double_click:
if mb.button_index == MOUSE_BUTTON_LEFT and mb.double_click and mb.pressed:
_show_preset_popup(mb.position)
elif mb.button_index == MOUSE_BUTTON_LEFT and not mb.double_click:
if mb.pressed:
if not _try_begin_resize(mb.position):
_begin_drag(mb.position)
elif _is_resizing:
_end_resize(mb.position)
elif _is_dragging:
_end_drag(mb.position)
elif event is InputEventMouseMotion and _is_dragging:
elif event is InputEventMouseMotion:
if _is_resizing:
_update_resize(event.position)
elif _is_dragging:
_update_drag(event.position)
else:
_update_hover_cursor(event.position)
func place_module(module: Control, col: int, row: int) -> void:
if not _is_valid_cell(col, row):
func _show_preset_popup(mouse_pos: Vector2) -> void:
var cell_pos := _cell_at_position(mouse_pos)
if cell_pos.x < 0 or cell_pos.y < 0:
return
_place_module_in_cell(module, col, row)
var module := _get_module_at(cell_pos.x, cell_pos.y)
if module == null:
return
# Don't show popup if already dragging or resizing
if _is_dragging or _is_resizing:
return
var popup := PopupMenu.new()
popup.name = "PresetPopup"
var names := ShaderPresets.get_preset_names()
for i in names.size():
popup.add_item(names[i], i)
popup.position = Vector2i(get_screen_position()) + Vector2i(mouse_pos)
add_child(popup)
popup.popup()
var on_select := func(id: int):
if id >= 0 and id < names.size():
ShaderPresets.apply_preset(module, names[id])
if is_instance_valid(popup):
popup.queue_free()
popup.id_pressed.connect(on_select)
# --------------------------------------------------------------------------
# Public API
# --------------------------------------------------------------------------
func place_module(module: Control, col: int, row: int, span_w: int = 1, span_h: int = 1) -> void:
_ensure_occupancy_for_span(col, row, span_w, span_h)
_set_module_grid_data(module, col, row, span_w, span_h)
_occupy_cells(module, col, row, span_w, span_h)
# Add as direct child of the grid
add_child(module)
if module not in _module_list:
_module_list.append(module)
module_placed.emit(module, col, row)
_layout_cells()
func remove_module(col: int, row: int) -> void:
if not _is_valid_cell(col, row):
func remove_module(module: Control) -> void:
if not is_instance_valid(module):
return
var module := _take_module_from_cell(col, row)
if module != null:
var col: int = module.get_meta("grid_col", -1)
var row: int = module.get_meta("grid_row", -1)
var w: int = module.get_meta("grid_w", 1)
var h: int = module.get_meta("grid_h", 1)
_clear_occupancy(col, row, w, h)
_module_list.erase(module)
if module.get_parent() == self:
remove_child(module)
module_removed.emit(module)
module.queue_free()
_layout_cells()
func get_grid_size() -> Vector2i:
@ -75,38 +172,259 @@ func get_cell_rect(col: int, row: int) -> Rect2:
return Rect2(cell.position, cell.size)
func _is_valid_cell(col: int, row: int) -> bool:
return row >= 0 and row < _cells.size() and col >= 0 and col < _cells[row].size()
# --------------------------------------------------------------------------
# Module grid-data helpers
# --------------------------------------------------------------------------
func _set_module_grid_data(module: Control, col: int, row: int, w: int, h: int) -> void:
module.set_meta("grid_col", col)
module.set_meta("grid_row", row)
module.set_meta("grid_w", w)
module.set_meta("grid_h", h)
func _get_module_grid_data(module: Control) -> Dictionary:
return {
col = module.get_meta("grid_col", 0),
row = module.get_meta("grid_row", 0),
w = module.get_meta("grid_w", 1),
h = module.get_meta("grid_h", 1),
}
func _occupy_cells(module: Control, col: int, row: int, w: int, h: int) -> void:
for r in range(row, row + h):
for c in range(col, col + w):
if r < _grid.size() and c < _grid[r].size():
_grid[r][c] = module
func _clear_occupancy(col: int, row: int, w: int, h: int) -> void:
for r in range(row, row + h):
for c in range(col, col + w):
if r < _grid.size() and c < _grid[r].size():
_grid[r][c] = null
func _ensure_occupancy_for_span(col: int, row: int, w: int, h: int) -> void:
var needed_rows := row + h
var needed_cols := col + w
while _grid.size() < needed_rows:
_grid.append([])
for r in range(_grid.size()):
while _grid[r].size() < needed_cols:
_grid[r].append(null)
if columns < needed_cols:
columns = needed_cols
if rows < needed_rows:
rows = needed_rows
func _is_valid_cell(col: int, row: int) -> bool:
return row >= 0 and row < _grid.size() and col >= 0 and col < _grid[row].size()
# --------------------------------------------------------------------------
# Occupancy checks
# --------------------------------------------------------------------------
func _span_fits(col: int, row: int, w: int, h: int, exclude: Control = null) -> bool:
for r in range(row, row + h):
for c in range(col, col + w):
if r < 0 or c < 0:
return false
if r >= _grid.size() or c >= _grid[r].size():
continue # beyond grid is OK — will expand
var occupant: Control = _grid[r][c]
if occupant != null and occupant != exclude:
return false
return true
# --------------------------------------------------------------------------
# Cell / position helpers
# --------------------------------------------------------------------------
func _cell_at_position(pos: Vector2) -> Vector2i:
if columns == 0 or rows == 0:
if columns == 0 or rows == 0 or _cell_w <= 0 or _cell_h <= 0:
return Vector2i(-1, -1)
var inner_x := margin + cell_spacing
var inner_y := margin + cell_spacing
var inner_w := size.x - margin * 2.0 - cell_spacing
var inner_h := size.y - margin * 2.0 - cell_spacing
var gap_x := cell_spacing * (columns - 1)
var gap_y := cell_spacing * (rows - 1)
var cell_w := (inner_w - gap_x) / columns
var cell_h := (inner_h - gap_y) / rows
var col := int((pos.x - inner_x) / (cell_w + cell_spacing))
var row := int((pos.y - inner_y) / (cell_h + cell_spacing))
var col := int((pos.x - inner_x) / (_cell_w + cell_spacing))
var row := int((pos.y - inner_y) / (_cell_h + cell_spacing))
col = clampi(col, 0, columns - 1)
row = clampi(row, 0, rows - 1)
return Vector2i(col, row)
func _get_cell_module(col: int, row: int) -> Control:
func _get_module_at(col: int, row: int) -> Control:
if not _is_valid_cell(col, row):
return null
var cell: PanelContainer = _cells[row][col]
if cell.get_child_count() > 0:
return cell.get_child(0)
return null
return _grid[row][col] as Control
func _get_module_in_grid() -> Array[Control]:
var result: Array[Control] = []
for r in range(rows):
for c in range(columns):
var mod := _get_module_at(c, r) as Control
if mod != null and mod not in result:
result.append(mod)
return result
# --------------------------------------------------------------------------
# Hover cursor
# --------------------------------------------------------------------------
func _update_hover_cursor(mouse_pos: Vector2) -> void:
var edge := _detect_resize_edge(mouse_pos)
mouse_default_cursor_shape = Control.CURSOR_ARROW
if edge != 0:
if edge & (EDGE_TOP | EDGE_BOTTOM):
mouse_default_cursor_shape = Control.CURSOR_VSIZE
elif edge & (EDGE_LEFT | EDGE_RIGHT):
mouse_default_cursor_shape = Control.CURSOR_HSIZE
func _detect_resize_edge(mouse_pos: Vector2) -> int:
for module in _module_list:
if not is_instance_valid(module):
continue
if not module.visible:
continue
var d := _get_module_grid_data(module)
var rect := _get_module_rect(d.col, d.row, d.w, d.h)
if not rect.has_point(mouse_pos):
continue
var edge := 0
var dist_left: float = abs(mouse_pos.x - rect.position.x)
var dist_right: float = abs(mouse_pos.x - (rect.position.x + rect.size.x))
var dist_top: float = abs(mouse_pos.y - rect.position.y)
var dist_bottom: float = abs(mouse_pos.y - (rect.position.y + rect.size.y))
if dist_left < RESIZE_THRESHOLD:
edge |= EDGE_LEFT
if dist_right < RESIZE_THRESHOLD:
edge |= EDGE_RIGHT
if dist_top < RESIZE_THRESHOLD:
edge |= EDGE_TOP
if dist_bottom < RESIZE_THRESHOLD:
edge |= EDGE_BOTTOM
return edge
return 0
# --------------------------------------------------------------------------
# Resize
# --------------------------------------------------------------------------
func _try_begin_resize(mouse_pos: Vector2) -> bool:
var edge := _detect_resize_edge(mouse_pos)
if edge == 0:
return false
# Find which module
for module in _module_list:
if not is_instance_valid(module):
continue
var d := _get_module_grid_data(module)
var rect := _get_module_rect(d.col, d.row, d.w, d.h)
if rect.has_point(mouse_pos):
_is_resizing = true
_resize_module = module
_resize_edge = edge
_resize_origin_col = d.col
_resize_origin_row = d.row
_resize_origin_w = d.w
_resize_origin_h = d.h
# Create preview ghost
_resize_preview = ColorRect.new()
_resize_preview.color = Color(0.3, 0.5, 1.0, 0.2)
_resize_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_resize_preview)
_update_resize_preview(mouse_pos)
# Show cells
_show_cells(true)
return true
return false
func _update_resize(mouse_pos: Vector2) -> void:
if _resize_preview == null:
return
_update_resize_preview(mouse_pos)
func _update_resize_preview(mouse_pos: Vector2) -> void:
var d := _get_module_grid_data(_resize_module)
var cell_pos := _cell_at_position(mouse_pos)
if cell_pos.x < 0 or cell_pos.y < 0:
_resize_preview.visible = false
return
var new_col: int = d.col
var new_row: int = d.row
var new_w: int = d.w
var new_h: int = d.h
if _resize_edge & EDGE_LEFT:
new_col = min(cell_pos.x, d.col + d.w - 1)
new_w = d.col + d.w - new_col
if _resize_edge & EDGE_RIGHT:
new_w = max(1, cell_pos.x - d.col + 1)
if _resize_edge & EDGE_TOP:
new_row = min(cell_pos.y, d.row + d.h - 1)
new_h = d.row + d.h - new_row
if _resize_edge & EDGE_BOTTOM:
new_h = max(1, cell_pos.y - d.row + 1)
# Clamp to grid
new_w = clampi(new_w, 1, columns - new_col)
new_h = clampi(new_h, 1, rows - new_row)
# Check for overlap (excluding ourselves)
var fits := _span_fits(new_col, new_row, new_w, new_h, _resize_module)
_resize_preview.visible = fits
if fits:
var prect := _get_module_rect(new_col, new_row, new_w, new_h)
_resize_preview.set_position(prect.position)
_resize_preview.set_size(prect.size)
# Store proposed span for end_resize
_resize_origin_col = new_col
_resize_origin_row = new_row
_resize_origin_w = new_w
_resize_origin_h = new_h
func _end_resize(_mouse_pos: Vector2) -> void:
if _resize_preview != null:
_resize_preview.queue_free()
_resize_preview = null
_show_cells(false)
if _resize_module != null and is_instance_valid(_resize_module):
# Clear old occupancy
var d := _get_module_grid_data(_resize_module)
_clear_occupancy(d.col, d.row, d.w, d.h)
# Apply new span
_set_module_grid_data(_resize_module, _resize_origin_col, _resize_origin_row, _resize_origin_w, _resize_origin_h)
_occupy_cells(_resize_module, _resize_origin_col, _resize_origin_row, _resize_origin_w, _resize_origin_h)
_layout_cells()
_is_resizing = false
_resize_module = null
_resize_edge = 0
# --------------------------------------------------------------------------
@ -120,43 +438,39 @@ func _begin_drag(mouse_pos: Vector2) -> void:
var col := cell_pos.x
var row := cell_pos.y
var module := _get_cell_module(col, row)
var module := _get_module_at(col, row)
if module == null:
return
_drag_source_col = col
_drag_source_row = row
var d := _get_module_grid_data(module)
_drag_source_col = d.col
_drag_source_row = d.row
# Show grid cells so the user can see drop zones
_show_cells(true)
# Calculate where the module sits in grid coordinates before reparenting.
# module.position is relative to the cell; cell.position is relative to us (the grid).
var cell_ref: PanelContainer = _cells[row][col]
var module_origin_in_grid := cell_ref.position + module.position
_drag_mouse_offset = mouse_pos - module_origin_in_grid
# Module position in grid space
var module_rect := _get_module_rect(d.col, d.row, d.w, d.h)
_drag_mouse_offset = mouse_pos - module_rect.position
_drag_module = _take_module_from_cell(col, row)
# Reparent module to top of tree (already a child if direct child of grid)
_drag_module = module
move_child(_drag_module, get_child_count())
_is_dragging = true
# Reparent to grid so it renders above all cells
add_child(_drag_module)
_drag_module.position = mouse_pos - _drag_mouse_offset
# Make the dragged module semi-transparent
_drag_module.modulate = Color(1, 1, 1, 0.7)
# Clear hover state
_clear_hover()
# Remove from occupancy temporarily
_clear_occupancy(d.col, d.row, d.w, d.h)
func _update_drag(mouse_pos: Vector2) -> void:
if _drag_module == null:
return
_drag_module.position = mouse_pos - _drag_mouse_offset
# Update hover highlight
var cell_pos := _cell_at_position(mouse_pos)
if cell_pos.x != _hover_col or cell_pos.y != _hover_row:
_clear_hover()
@ -174,11 +488,12 @@ func _end_drag(mouse_pos: Vector2) -> void:
_show_cells(false)
_drag_module.modulate = Color(1, 1, 1, 1)
var d := _get_module_grid_data(_drag_module)
var cell_pos := _cell_at_position(mouse_pos)
var target_col := cell_pos.x
var target_row := cell_pos.y
# Check whether the mouse is actually inside the cell bounds (not in the gap)
# Check if mouse is actually inside the cell bounds
var on_cell := false
if _is_valid_cell(target_col, target_row):
var cell_ref: PanelContainer = _cells[target_row][target_col]
@ -186,42 +501,50 @@ func _end_drag(mouse_pos: Vector2) -> void:
if not on_cell or not _is_valid_cell(target_col, target_row):
_drop_to_source()
_layout_cells()
_finish_drag()
return
# Remove from its temporary parent (the grid)
_drag_module.get_parent().remove_child(_drag_module)
var existing := _get_cell_module(target_col, target_row)
# Check if target is occupied
var existing := _get_module_at(target_col, target_row)
if existing != null:
# SWAP: put existing in source, dragged module in target
existing.get_parent().remove_child(existing)
var was_source_valid := _is_valid_cell(_drag_source_col, _drag_source_row)
if was_source_valid:
_place_module_in_cell(existing, _drag_source_col, _drag_source_row)
module_placed.emit(existing, _drag_source_col, _drag_source_row)
var ed := _get_module_grid_data(existing)
_swap_modules(_drag_module, d.col, d.row, d.w, d.h, existing, ed)
var ad := _get_module_grid_data(_drag_module)
module_placed.emit(_drag_module, ad.col, ad.row)
module_placed.emit(existing, d.col, d.row)
else:
existing.queue_free()
_place_module_in_cell(_drag_module, target_col, target_row)
module_placed.emit(_drag_module, target_col, target_row)
# Place in new position
_clear_occupancy(d.col, d.row, d.w, d.h)
_set_module_grid_data(_drag_module, target_col, target_row, d.w, d.h)
if _span_fits(target_col, target_row, d.w, d.h, _drag_module):
_occupy_cells(_drag_module, target_col, target_row, d.w, d.h)
else:
# Place in the empty cell
_place_module_in_cell(_drag_module, target_col, target_row)
_occupy_cells(_drag_module, d.col, d.row, d.w, d.h)
_set_module_grid_data(_drag_module, d.col, d.row, d.w, d.h)
module_placed.emit(_drag_module, target_col, target_row)
_layout_cells()
_finish_drag()
func _swap_modules(a: Control, a_col: int, a_row: int, a_w: int, a_h: int,
b: Control, bd: Dictionary) -> void:
_clear_occupancy(a_col, a_row, a_w, a_h)
_clear_occupancy(bd.col, bd.row, bd.w, bd.h)
_set_module_grid_data(a, bd.col, bd.row, bd.w, bd.h)
_set_module_grid_data(b, a_col, a_row, a_w, a_h)
_occupy_cells(a, bd.col, bd.row, bd.w, bd.h)
_occupy_cells(b, a_col, a_row, a_w, a_h)
func _drop_to_source() -> void:
if _drag_module == null:
return
if _is_valid_cell(_drag_source_col, _drag_source_row):
_drag_module.get_parent().remove_child(_drag_module)
_place_module_in_cell(_drag_module, _drag_source_col, _drag_source_row)
module_placed.emit(_drag_module, _drag_source_col, _drag_source_row)
else:
_drag_module.queue_free()
var d := _get_module_grid_data(_drag_module)
_occupy_cells(_drag_module, d.col, d.row, d.w, d.h)
func _finish_drag() -> void:
@ -231,6 +554,18 @@ func _finish_drag() -> void:
_drag_source_row = -1
# --------------------------------------------------------------------------
# Module rect in grid coordinates
# --------------------------------------------------------------------------
func _get_module_rect(col: int, row: int, w: int, h: int) -> Rect2:
var x := margin + cell_spacing + col * (_cell_w + cell_spacing)
var y := margin + cell_spacing + row * (_cell_h + cell_spacing)
var mw := w * _cell_w + (w - 1) * cell_spacing
var mh := h * _cell_h + (h - 1) * cell_spacing
return Rect2(x, y, mw, mh)
# --------------------------------------------------------------------------
# Cell highlights
# --------------------------------------------------------------------------
@ -255,118 +590,22 @@ func _highlight_cell(col: int, row: int, highlighted: bool) -> void:
style.border_color = Color(0.25, 0.25, 0.35, 1.0)
# --------------------------------------------------------------------------
# Module placement helpers
# --------------------------------------------------------------------------
func _take_module_from_cell(col: int, row: int) -> Control:
if not _is_valid_cell(col, row):
return null
var cell: PanelContainer = _cells[row][col]
for child in cell.get_children():
cell.remove_child(child)
_module_positions.erase("%d,%d" % [col, row])
return child
return null
func _place_module_in_cell(module: Control, col: int, row: int) -> void:
if not _is_valid_cell(col, row):
return
var cell: PanelContainer = _cells[row][col]
# Clear existing children
for child in cell.get_children():
cell.remove_child(child)
if is_instance_valid(child):
child.queue_free()
cell.add_child(module)
_module_positions["%d,%d" % [col, row]] = module
func _set_cell_child(cell: PanelContainer, child: Control) -> void:
for existing in cell.get_children():
cell.remove_child(existing)
if is_instance_valid(existing):
existing.queue_free()
cell.add_child(child)
# --------------------------------------------------------------------------
# List-reflow helpers
# --------------------------------------------------------------------------
# Returns all modules in the grid, in row-major order (left-to-right, top-to-bottom).
func _collect_all_modules() -> Array[Control]:
var result: Array[Control] = []
for r in range(rows):
for c in range(columns):
var mod := _get_cell_module(c, r)
if mod != null:
result.append(mod)
return result
# Clears every cell, then places the given modules in row-major order,
# adding extra rows as needed to fit them all.
func _reflow_modules(modules: Array[Control]) -> void:
# Remove all modules from cells
for r in range(rows):
for c in range(columns):
_take_module_from_cell(c, r)
if columns == 0:
return
# Ensure enough rows exist
var needed := maxi(rows, ceili(float(modules.size()) / float(columns)))
while _cells.size() < needed:
_add_row()
rows = _cells.size()
# Place modules sequentially
for i in range(modules.size()):
var c := i % columns
var r := floori(i / columns)
_place_module_in_cell(modules[i], c, r)
module_placed.emit(modules[i], c, r)
func _add_row() -> void:
var new_row := _cells.size()
_cells.append([])
for col in range(columns):
var cell := PanelContainer.new()
cell.name = "Cell_%d_%d" % [col, new_row]
cell.mouse_filter = Control.MOUSE_FILTER_PASS
_style_cell(cell, col, new_row)
add_child(cell)
_cells[new_row].append(cell)
rows = _cells.size()
_layout_cells()
func _count_modules_in_grid() -> int:
var count := 0
for r in range(rows):
for c in range(columns):
if _get_cell_module(c, r) != null:
count += 1
return count
# --------------------------------------------------------------------------
# Grid rebuild
# --------------------------------------------------------------------------
func _rebuild_grid() -> void:
# Cancel any active drag
if _is_dragging:
_cancel_drag()
if _is_resizing:
_end_resize(Vector2.ZERO)
# Clean up orphaned popups from preset selection
for child in get_children():
if child is PopupMenu:
child.queue_free()
var avail := size - Vector2(margin * 2.0, margin * 2.0)
# If layout hasn't resolved yet (size too small), build a minimum 1x1 grid
# so modules can be placed. The resized signal will recalculate later.
if avail.x < cell_min_size.x or avail.y < cell_min_size.y:
if _cells.size() > 0:
_layout_cells()
@ -387,8 +626,7 @@ func _rebuild_grid() -> void:
var new_cols := maxi(1, int(avail.x / (cell_min_size.x + cell_spacing)))
# Ensure enough rows for all current modules after column change
var module_count := _count_modules_in_grid()
var module_count := _module_list.size()
var min_rows := maxi(1, ceili(float(module_count) / float(new_cols)))
var avail_rows := maxi(1, int(avail.y / (cell_min_size.y + cell_spacing)))
var new_rows := maxi(avail_rows, min_rows)
@ -410,13 +648,8 @@ func _rebuild_grid() -> void:
func _cancel_drag() -> void:
if _drag_module != null:
_drag_module.modulate = Color(1, 1, 1, 1)
if _is_valid_cell(_drag_source_col, _drag_source_row):
var parent := _drag_module.get_parent()
if parent != null:
parent.remove_child(_drag_module)
_place_module_in_cell(_drag_module, _drag_source_col, _drag_source_row)
else:
_drag_module.queue_free()
var d := _get_module_grid_data(_drag_module)
_occupy_cells(_drag_module, d.col, d.row, d.w, d.h)
_show_cells(false)
_clear_hover()
_drag_module = null
@ -425,6 +658,10 @@ func _cancel_drag() -> void:
_drag_source_row = -1
# --------------------------------------------------------------------------
# Cell building / teardown / styling
# --------------------------------------------------------------------------
func _build_cells() -> void:
_cells.clear()
for row in range(rows):
@ -439,7 +676,6 @@ func _build_cells() -> void:
func _style_cell(cell: PanelContainer, col: int, row: int) -> void:
# Hidden style — used when not dragging
var hidden := StyleBoxFlat.new()
hidden.bg_color = Color.TRANSPARENT
hidden.border_width_left = 1
@ -452,7 +688,6 @@ func _style_cell(cell: PanelContainer, col: int, row: int) -> void:
hidden.corner_radius_bottom_right = 12
hidden.corner_radius_bottom_left = 12
# Visible style — used during drag, may be modified by highlights
var visible := StyleBoxFlat.new()
visible.bg_color = Color(0.14, 0.14, 0.18, 1.0)
visible.border_width_left = 1
@ -486,62 +721,22 @@ func _show_cells(show: bool) -> void:
func _teardown_cells() -> void:
_cell_styles.clear()
for child in get_children():
# Keep modules — only remove cells
if child is PanelContainer and child.name.begins_with("Cell_"):
remove_child(child)
if is_instance_valid(child):
child.queue_free()
_cells.clear()
func _layout_cells() -> void:
if rows == 0 or columns == 0:
return
# --------------------------------------------------------------------------
# Layout
# --------------------------------------------------------------------------
var inner_w := maxf(0.0, size.x - margin * 2.0 - cell_spacing)
var inner_h := maxf(0.0, size.y - margin * 2.0 - cell_spacing)
var gap_x := cell_spacing * (columns - 1)
var gap_y := cell_spacing * (rows - 1)
var cell_w := maxf(1.0, (inner_w - gap_x) / columns)
var cell_h := maxf(1.0, (inner_h - gap_y) / rows)
for row in range(rows):
for col in range(columns):
var cell: PanelContainer = _cells[row][col]
if not is_instance_valid(cell):
continue
var x := margin + cell_spacing + col * (cell_w + cell_spacing)
var y := margin + cell_spacing + row * (cell_h + cell_spacing)
cell.set_position(Vector2(x, y))
cell.set_size(Vector2(cell_w, cell_h))
func _save_modules() -> void:
_module_positions.clear()
for row in range(_cells.size()):
for col in range(_cells[row].size()):
var cell: PanelContainer = _cells[row][col]
if cell.get_child_count() > 0:
var mod := cell.get_child(0)
cell.remove_child(mod)
_module_positions["%d,%d" % [col, row]] = mod
func _restore_modules() -> void:
# Collect modules in insertion order (row-major from _save_modules)
var module_list: Array[Control] = []
for key in _module_positions:
module_list.append(_module_positions[key])
_module_positions.clear()
if module_list.is_empty():
return
# Reflow onto the new grid dimensions
var module_count := module_list.size()
var needed := maxi(rows, ceili(float(module_count) / float(columns)))
while _cells.size() < needed:
var new_row := _cells.size()
func _ensure_cells_match_grid() -> void:
while _cells.size() < rows:
_cells.append([])
var new_row := _cells.size() - 1
for col in range(columns):
var cell := PanelContainer.new()
cell.name = "Cell_%d_%d" % [col, new_row]
@ -549,10 +744,140 @@ func _restore_modules() -> void:
_style_cell(cell, col, new_row)
add_child(cell)
_cells[new_row].append(cell)
rows = _cells.size()
for i in range(module_list.size()):
var c := i % columns
var r := floori(i / columns)
if _is_valid_cell(c, r):
_place_module_in_cell(module_list[i], c, r)
func _layout_cells() -> void:
if rows == 0 or columns == 0:
return
_ensure_cells_match_grid()
var inner_w := maxf(0.0, size.x - margin * 2.0 - cell_spacing)
var inner_h := maxf(0.0, size.y - margin * 2.0 - cell_spacing)
var gap_x := cell_spacing * (columns - 1)
var gap_y := cell_spacing * (rows - 1)
_cell_w = maxf(1.0, (inner_w - gap_x) / columns)
_cell_h = maxf(1.0, (inner_h - gap_y) / rows)
# Position cells
for row in range(rows):
for col in range(columns):
if row >= _cells.size() or col >= _cells[row].size():
continue
var cell: PanelContainer = _cells[row][col]
if not is_instance_valid(cell):
continue
var x := margin + cell_spacing + col * (_cell_w + cell_spacing)
var y := margin + cell_spacing + row * (_cell_h + cell_spacing)
cell.set_position(Vector2(x, y))
cell.set_size(Vector2(_cell_w, _cell_h))
# Position modules
for module in _module_list:
if not is_instance_valid(module):
continue
var d := _get_module_grid_data(module)
var rect := _get_module_rect(d.col, d.row, d.w, d.h)
module.set_position(rect.position)
module.set_size(rect.size)
# --------------------------------------------------------------------------
# Save / restore (used during grid rebuild)
# --------------------------------------------------------------------------
func _save_modules() -> void:
# Modules already have their grid data in metadata
pass
func _restore_modules() -> void:
# Rebuild grid
_grid.clear()
for r in range(rows):
_grid.append([])
for c in range(columns):
_grid[r].append(null)
if _module_list.is_empty():
return
# Ensure enough rows
while _grid.size() < rows:
_grid.append([])
for c in range(columns):
_grid[_grid.size() - 1].append(null)
# Re-place modules row-major
var reordered: Array[Control] = []
for r in range(rows):
for c in range(columns):
for module in _module_list:
if module in reordered:
continue
var d := _get_module_grid_data(module)
if d.col == c and d.row == r:
if _span_fits(c, r, d.w, d.h, null):
# Ensure grid is big enough
_ensure_occupancy_for_span(c, r, d.w, d.h)
_occupy_cells(module, c, r, d.w, d.h)
reordered.append(module)
break
# Add any modules that didn't find their spot
for module in _module_list:
if module not in reordered:
reordered.append(module)
_module_list = reordered
# Place orphaned modules
for module in _module_list:
if module.get_parent() != self:
add_child(module)
# Check if it's in the grid
var d := _get_module_grid_data(module)
if _get_module_at(d.col, d.row) != module:
# Find a spot
_find_and_place(module)
# Rebuild columns/rows from grid occupancy
_ensure_occupancy_from_grid()
_layout_cells()
func _ensure_occupancy_from_grid() -> void:
var max_c := 0
var max_r := 0
for r in range(_grid.size()):
for c in range(_grid[r].size()):
if _grid[r][c] != null:
max_c = maxi(max_c, c + 1)
max_r = maxi(max_r, r + 1)
columns = maxi(columns, max_c)
rows = maxi(rows, max_r)
func _find_and_place(module: Control) -> void:
var d := _get_module_grid_data(module)
# Try the desired position first
if _span_fits(d.col, d.row, d.w, d.h, null):
_ensure_occupancy_for_span(d.col, d.row, d.w, d.h)
_occupy_cells(module, d.col, d.row, d.w, d.h)
return
# Find first available position
for r in range(rows):
for c in range(columns):
if _span_fits(c, r, d.w, d.h, null):
_set_module_grid_data(module, c, r, d.w, d.h)
_ensure_occupancy_for_span(c, r, d.w, d.h)
_occupy_cells(module, c, r, d.w, d.h)
return
# No room — expand grid
var new_col := columns
var new_row := 0
_ensure_occupancy_for_span(new_col, new_row, d.w, d.h)
columns = new_col + d.w
_set_module_grid_data(module, new_col, new_row, d.w, d.h)
_occupy_cells(module, new_col, new_row, d.w, d.h)

128
scripts/shader_presets.gd Normal file
View file

@ -0,0 +1,128 @@
class_name ShaderPresets
# Collection of vial_fill shader presets.
# Each preset is a Dictionary with a "name" key and shader-parameter key/value pairs.
# Use apply_preset(module, preset_name) to set parameters on a module's ShaderMaterial.
static var presets: Array[Dictionary] = [
{
"name": "Vivid Vial",
"liquid_color": Color(0.2, 0.5, 0.8, 1.0),
"wave_amp": 0.012,
"wave_freq": 4.0,
"wave_strength": 0.05,
"ripple_speed": 1.0,
"edge_color": Color(0.3, 0.6, 1.0, 1.0),
"glow_intensity": 1.5,
"edge_pulse": 1.0,
"noise_scale": 1.0,
"swirl_strength": 0.5,
"hue_shift_speed": 0.0,
},
{
"name": "Emerald Deep",
"liquid_color": Color(0.1, 0.6, 0.3, 1.0),
"wave_amp": 0.015,
"wave_freq": 5.0,
"wave_strength": 0.08,
"ripple_speed": 0.8,
"edge_color": Color(0.2, 0.8, 0.5, 1.0),
"glow_intensity": 1.2,
"edge_pulse": 0.8,
"noise_scale": 1.2,
"swirl_strength": 0.3,
"hue_shift_speed": 0.0,
},
{
"name": "Lava Flow",
"liquid_color": Color(0.8, 0.2, 0.05, 1.0),
"wave_amp": 0.025,
"wave_freq": 3.0,
"wave_strength": 0.15,
"ripple_speed": 0.5,
"edge_color": Color(1.0, 0.5, 0.1, 1.0),
"glow_intensity": 2.0,
"edge_pulse": 1.5,
"noise_scale": 0.8,
"swirl_strength": 1.0,
"hue_shift_speed": 0.0,
},
{
"name": "Neon Dream",
"liquid_color": Color(0.6, 0.2, 0.9, 1.0),
"wave_amp": 0.01,
"wave_freq": 6.0,
"wave_strength": 0.06,
"ripple_speed": 2.0,
"edge_color": Color(0.7, 0.3, 1.0, 1.0),
"glow_intensity": 2.5,
"edge_pulse": 1.2,
"noise_scale": 1.5,
"swirl_strength": 0.8,
"hue_shift_speed": 0.0,
},
{
"name": "Deep Purple",
"liquid_color": Color(0.3, 0.1, 0.5, 1.0),
"wave_amp": 0.008,
"wave_freq": 3.5,
"wave_strength": 0.04,
"ripple_speed": 0.6,
"edge_color": Color(0.5, 0.2, 0.8, 1.0),
"glow_intensity": 1.0,
"edge_pulse": 0.7,
"noise_scale": 1.0,
"swirl_strength": 0.4,
"hue_shift_speed": 0.0,
},
{
"name": "Rainbow Swirl",
"liquid_color": Color(0.3, 0.5, 0.8, 1.0),
"wave_amp": 0.012,
"wave_freq": 4.0,
"wave_strength": 0.1,
"ripple_speed": 1.5,
"edge_color": Color(0.5, 0.7, 1.0, 1.0),
"glow_intensity": 1.5,
"edge_pulse": 1.0,
"noise_scale": 1.5,
"swirl_strength": 1.2,
"hue_shift_speed": 1.5,
},
{
"name": "Frostbite",
"liquid_color": Color(0.5, 0.8, 1.0, 1.0),
"wave_amp": 0.006,
"wave_freq": 7.0,
"wave_strength": 0.03,
"ripple_speed": 2.5,
"edge_color": Color(0.7, 0.9, 1.0, 1.0),
"glow_intensity": 0.8,
"edge_pulse": 0.5,
"noise_scale": 2.0,
"swirl_strength": 0.2,
"hue_shift_speed": 0.0,
},
]
static func get_preset_names() -> PackedStringArray:
var names: PackedStringArray = []
for p in presets:
names.append(p["name"])
return names
static func apply_preset(module: Control, preset_name: String) -> void:
var vial_fill: ColorRect = module.find_child("VialFill", true, false)
if vial_fill == null:
return
var mat := vial_fill.material as ShaderMaterial
if mat == null:
return
for p in presets:
if p["name"] == preset_name:
for key in p:
if key == "name":
continue
mat.set_shader_parameter(key, p[key])
return

View file

@ -128,15 +128,31 @@ void fragment() {
// tiny sparkle
effect_rgb += 0.03 * vec3(sin(t * 3.0), cos(t * 2.0), noise_val * 0.1);
// --- 3D water surface ---
float d_surf = UV.y - fill_line; // positive = below surface
float d_abs = abs(d_surf);
// subsurface light scatter — brightest just below surface, fades with depth
float subsurface = exp(-d_surf * 25.0) * step(0.0, d_surf);
effect_rgb *= 1.0 + subsurface * 0.25;
// --- composite ---
vec3 col = bg_color.rgb;
col = mix(col, effect_rgb, liquid);
// surface foam line (wider glow + bright core)
float foam_glow = smoothstep(fill_line - 0.02, fill_line, UV.y) - smoothstep(fill_line, fill_line + 0.004, UV.y);
col += foam_glow * vec3(0.5, 0.65, 0.9) * 0.6 * liquid;
float foam_core = smoothstep(fill_line - 0.006, fill_line, UV.y) - smoothstep(fill_line, fill_line + 0.001, UV.y);
col += foam_core * vec3(0.7, 0.85, 1.0) * liquid;
// foam glow — smooth double-sided falloff (gaussian-like)
float foam_glow = exp(-d_abs * 70.0);
float foam_core = exp(-d_abs * 350.0);
vec3 foam_tint = vec3(0.6, 0.75, 0.95);
col += foam_glow * foam_tint * 0.5;
col += foam_core * vec3(0.75, 0.9, 1.0) * 0.6;
// wave-slope highlight — brightens at wave crests facing the viewer
float wave_slope = (cos(UV.x * wf + TIME * 5.0) * wf
+ cos(UV.x * wf * 1.7 + TIME * 7.0 + 1.3) * wf * 1.7 * 0.5
+ cos(UV.x * wf * 0.6 + TIME * 2.5 + 2.9) * wf * 0.6 * 0.3) * edge_damp / 1.8 * wave_amp;
float slope_highlight = max(0.0, wave_slope) * exp(-d_abs * 200.0);
col += slope_highlight * vec3(0.6, 0.8, 1.0) * 0.4;
// border
col = mix(col, border_color.rgb, outer - inner);