diff --git a/.claude/commands/new-texture.md b/.claude/commands/new-texture.md new file mode 100644 index 0000000..1327247 --- /dev/null +++ b/.claude/commands/new-texture.md @@ -0,0 +1,156 @@ +# New Texture + +Generate a sci-fi themed texture set for an Atlas block using the texture_lib framework. + +## Arguments + +$ARGUMENTS + +## Instructions + +You are generating production-quality textures for a Minecraft block in the Atlas plugin. All textures use a consistent sci-fi industrial art direction and are built programmatically with Python/Pillow using the shared `scripts/texture_lib.py` library. + +Before writing any code, read `scripts/texture_lib.py` and at least one existing generator script in `scripts/generate_*_textures.py` to understand the established patterns and available utilities. + +### Art Direction + +All Atlas block textures share a unified visual language: + +- **Housing**: Dark matte armored panels (base ~38,40,48 RGB) with subtle color variation +- **Surface pattern**: Hexagonal honeycomb grid across panel surfaces — compute hex cell centers, draw each cell as a polygon with dark fill and subtle outline +- **Borders**: Thick dark outer frame with inner bevel (light top-left, dark bottom-right) for a raised appearance +- **Seam lines**: Thin dark panel seams dividing faces into logical sections, optionally glowing with `add_glowing_seam()` when the block is active +- **Hardware details**: Bolts/rivets at corners, along seam lines, and around key features — drawn as small filled circles with highlight centers +- **Functional elements**: Vent grates (horizontal slats), data plates (beveled rectangles with decorative line details), status LEDs, pipe connectors, gauge displays +- **Active/energy states**: Glowing elements using `add_radial_glow()` and `add_glow_ring()` — energy colors shift to communicate state (red=low/danger, amber/orange=medium/warm, yellow=high, green=full/ready, blue/cyan=active) +- **Gauges**: Circular gauge faces with metallic frames, tick marks, and colored arcs that fill clockwise to indicate levels + +### Resolution + +- Default resolution is **1024x1024** for new textures (maximum detail budget) +- The resolution is set as a module-level `S` constant and all coordinates should scale relative to `S` +- Smaller resolutions (128, 256, 512) are acceptable if the block is simple or the user requests it + +### File Structure + +Create the texture generator as a standalone Python script: + +``` +scripts/generate_{block_id}_textures.py +``` + +The script must: +1. Import shared utilities from `texture_lib` (never duplicate them) +2. Define a block-specific color palette at the top of the file +3. Build each face as a separate function (`make_top()`, `make_side()`, `make_bottom()`, etc.) +4. Handle visual state variants by layering energy/glow effects onto a base face +5. Use `save_textures()` from texture_lib to write all PNGs to the standard output directory +6. Be runnable standalone via `python3 scripts/generate_{block_id}_textures.py` + +### Required Steps + +#### Step 1: Understand the Block + +From the user's description and/or the block's CraftEngine YAML config, determine: + +1. **Block ID** (e.g., `small_battery`, `fluid_pump`) +2. **Parent model type** — determines which faces need unique textures: + - `cube_bottom_top`: 3 textures — top, bottom, side (side shared on N/S/E/W) + - `cube`: 6 textures — north, south, east, west, up, down + - `cube_all`: 1 texture — all faces identical +3. **Visual states** — what variants exist? (e.g., inactive/active, charge levels, fluid types) +4. **Which faces change per state** — typically only one or two faces change; the rest are shared +5. **Energy/theme colors** — what glow colors fit this block's purpose? + +If any of this is unclear, ask before proceeding. + +#### Step 2: Design the Face Layout + +Plan what goes on each face. Follow these conventions: + +- **Top face**: The "showcase" face — gauges, status displays, functional indicators. This is usually what the player sees when looking down at placed blocks. +- **Side faces**: Armored panels with structural details — hex grid, seam lines, bolt rows, vent grates, data plates, pipe connectors, status LEDs. +- **Bottom face**: Heavy base plate — ventilation grille, mounting feet with bolts, structural cross-seams. + +For directional blocks, the **front face** (facing the player) becomes the showcase face instead of top. + +#### Step 3: Build the Color Palette + +Define colors at the top of the script. Group them by purpose: + +```python +# Housing / armor — keep consistent across all blocks +ARMOR_DARK = (38, 40, 48, 255) # base matte housing +ARMOR_MID = (52, 56, 66, 255) # lighter panel areas +ARMOR_LIGHT = (70, 75, 88, 255) # highlights / bevels +HEX_LINE = (48, 52, 62, 255) # honeycomb grid lines +EDGE_DARK = (22, 24, 30, 255) # darkest edges +SEAM_COLOR = (30, 33, 40, 255) # panel seams +RIVET_COLOR = (100, 108, 125, 255) # bolt highlights +FRAME_DARK = (28, 30, 38, 255) # frame dark tone +FRAME_MID = (58, 62, 74, 255) # frame mid tone +FRAME_LIGHT = (80, 86, 100, 255) # frame highlight + +# Energy colors (customize per block) +ENERGY_COLOR = (...) # primary active color +ENERGY_GLOW = (...) # brighter glow variant +``` + +Keep the housing/armor palette consistent across all blocks. Only the energy/accent colors should vary per block. + +#### Step 4: Implement the Generator + +Follow these established patterns from existing generators: + +**Hex armor base**: Compute hex cell centers once at module level, then draw cells as polygons with dark fill and subtle outlines. This is the foundation for all armored faces. + +```python +def _hex_vertices(cx, cy, radius): + """Return 6 vertices for a flat-top hexagon.""" + ... + +HEX_CENTERS = _compute_hex_centers(S, HEX_RADIUS) + +def make_hex_armor_face(): + img = new_img(S, ARMOR_DARK) + draw = ImageDraw.Draw(img) + add_border(draw, S, EDGE_DARK, width=6) + # bevel, hex cells, etc. + ... +``` + +**Face functions**: Build each face as a function on top of the armor base. Add face-specific details (bolts, seams, vents, gauges) on top. + +**State variants**: Generate the base face once, then create variants by adding energy/glow effects. Don't rebuild the entire face for each state. + +**Key texture_lib functions**: +- `new_img(size, fill)` — create base image +- `add_border(draw, size, color, width)` — outer border +- `draw_hex_grid_lines_only(img, hex_radius, line_color, ...)` — honeycomb pattern (alternative to manual hex cell drawing) +- `add_radial_glow(img, cx, cy, radius, color, intensity)` — soft circular glow +- `add_glow_ring(img, cx, cy, r_inner, r_outer, color)` — ring-shaped glow +- `add_glowing_seam(img, start, end, seam_color, glow_color, ...)` — glowing panel seams +- `lerp_color(c1, c2, t)` — color interpolation +- `blend_over(base, overlay, alpha)` — alpha blending +- `save_textures(textures)` — save all PNGs + +#### Step 5: Generate and Verify + +1. Run the script: `python3 scripts/generate_{block_id}_textures.py` +2. Visually inspect every generated texture by reading the PNG files +3. Verify the texture names match what the CraftEngine YAML references +4. Check that state variants are visually distinct from each other + +### Checklist + +Before finishing, verify: +- [ ] Script imports from `texture_lib` (no duplicated utilities) +- [ ] Color palette uses the standard armor colors for housing +- [ ] Hex honeycomb pattern is present on all armored faces +- [ ] Beveled borders on all faces +- [ ] Bolts/rivets at structurally appropriate locations +- [ ] State variants are visually distinct (different energy colors/intensities) +- [ ] All textures referenced in the CraftEngine YAML are generated +- [ ] Script runs successfully and all PNGs are created +- [ ] Each texture has been visually inspected +- [ ] No line in the script exceeds 140 characters diff --git a/scripts/generate_small_battery_textures.py b/scripts/generate_small_battery_textures.py new file mode 100644 index 0000000..91efc79 --- /dev/null +++ b/scripts/generate_small_battery_textures.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""Generate sci-fi energy cell textures for the Small Battery at 1024x1024. + +Concept: Full hex armor housing. Hex cells fill from bottom upward with +energy color to indicate charge level. Side faces show the fill directly. +Top face shows a circular gauge representing the same charge level. + +Charge states: + empty — all dark + low — bottom 1/3 red + medium — bottom 1/2 orange-yellow + high — bottom 3/4 yellow + full — entire face green + +Creates 12 textures: + Top (5): small_battery, _low, _medium, _high, _full + Side (5): small_battery_side, _side_low, _side_medium, _side_high, _side_full + Bottom(1): small_battery_bottom +""" + +import math +from PIL import Image, ImageDraw + +from texture_lib import ( + new_img, add_border, lerp_color, blend_over, + draw_hex_grid_lines_only, add_radial_glow, + save_textures, +) + +S = 1024 # texture size + +# --------------------------------------------------------------------------- +# Color Palette +# --------------------------------------------------------------------------- + +# Housing / armor +ARMOR_DARK = (38, 40, 48, 255) +ARMOR_MID = (52, 56, 66, 255) +ARMOR_LIGHT = (70, 75, 88, 255) +HEX_LINE = (48, 52, 62, 255) +EDGE_DARK = (22, 24, 30, 255) +SEAM_COLOR = (30, 33, 40, 255) +RIVET_COLOR = (100, 108, 125, 255) +FRAME_DARK = (28, 30, 38, 255) +FRAME_MID = (58, 62, 74, 255) +FRAME_LIGHT = (80, 86, 100, 255) + +# Charge state colors +COLOR_RED = (220, 45, 30, 255) +COLOR_RED_GLOW = (255, 60, 40, 255) +COLOR_ORANGE = (240, 160, 30, 255) +COLOR_ORANGE_GLOW = (255, 190, 50, 255) +COLOR_YELLOW = (230, 220, 40, 255) +COLOR_YELLOW_GLOW = (255, 245, 80, 255) +COLOR_GREEN = (50, 210, 50, 255) +COLOR_GREEN_GLOW = (80, 255, 80, 255) + +# Charge state definitions: (fill_fraction, energy_color, glow_color) +CHARGE_STATES = { + "empty": (0.0, None, None), + "low": (1/3, COLOR_RED, COLOR_RED_GLOW), + "medium": (1/2, COLOR_ORANGE, COLOR_ORANGE_GLOW), + "high": (3/4, COLOR_YELLOW, COLOR_YELLOW_GLOW), + "full": (1.0, COLOR_GREEN, COLOR_GREEN_GLOW), +} + +# Hex geometry for the armor cells +HEX_RADIUS = 28 +HEX_H = HEX_RADIUS * math.sqrt(3) +HEX_COL_STEP = HEX_RADIUS * 1.5 +HEX_ROW_STEP = HEX_H + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def draw_thick_circle(draw, cx, cy, radius, color, width): + """Draw a circle with a given line width.""" + for w in range(width): + r = radius - w + if r > 0: + draw.ellipse([cx - r, cy - r, cx + r, cy + r], + outline=color) + + +def draw_filled_circle(draw, cx, cy, radius, fill, outline=None): + """Draw a filled circle.""" + draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius], + fill=fill, outline=outline) + + +def add_bevel_border(draw, x1, y1, x2, y2, light, dark, width=2): + """Draw a beveled rectangle border (raised appearance).""" + for i in range(width): + draw.line([(x1 + i, y1 + i), (x2 - i, y1 + i)], fill=light) + draw.line([(x1 + i, y1 + i), (x1 + i, y2 - i)], fill=light) + draw.line([(x1 + i, y2 - i), (x2 - i, y2 - i)], fill=dark) + draw.line([(x2 - i, y1 + i), (x2 - i, y2 - i)], fill=dark) + + +# --------------------------------------------------------------------------- +# Hex cell grid computation +# --------------------------------------------------------------------------- + +def _hex_vertices(cx, cy, radius): + """Return 6 vertices for a flat-top hexagon.""" + verts = [] + for i in range(6): + angle = math.radians(60 * i) + verts.append((cx + radius * math.cos(angle), + cy + radius * math.sin(angle))) + return verts + + +def _compute_hex_centers(size, radius, inset=12): + """Compute all hex cell center positions for a grid covering the image.""" + centers = [] + col_step = radius * 1.5 + row_step = radius * math.sqrt(3) + cols = int((size - 2 * inset) / col_step) + 3 + rows = int((size - 2 * inset) / row_step) + 3 + + for col in range(-1, cols + 1): + for row in range(-1, rows + 1): + cx = inset + col * col_step + cy = inset + row * row_step + (col % 2) * (row_step / 2) + # Only include cells whose center is within the image + if -radius < cx < size + radius and -radius < cy < size + radius: + centers.append((cx, cy)) + return centers + + +HEX_CENTERS = _compute_hex_centers(S, HEX_RADIUS) + + +# --------------------------------------------------------------------------- +# Hex armor face with charge fill +# --------------------------------------------------------------------------- + +def make_hex_armor_face(fill_fraction=0.0, energy_color=None, + glow_color=None): + """Create a hex armor face with cells lit below a Y threshold. + + fill_fraction: 0.0 = all dark, 1.0 = all lit. + Energy fills from bottom upward. + """ + img = new_img(S, ARMOR_DARK) + draw = ImageDraw.Draw(img) + + # Outer border + add_border(draw, S, EDGE_DARK, width=6) + add_bevel_border(draw, 6, 6, S - 7, S - 7, ARMOR_LIGHT, EDGE_DARK, + width=3) + + # Compute the Y threshold: cells with center below this are lit + # fill_fraction=1.0 means everything lit (threshold at top) + # fill_fraction=0.0 means nothing lit (threshold below bottom) + margin = 12 + fill_y = S - margin - fill_fraction * (S - 2 * margin) + + # Draw hex cells — all dark with subtle outlines + for cx, cy in HEX_CENTERS: + verts = _hex_vertices(cx, cy, HEX_RADIUS - 1) + draw.polygon(verts, fill=ARMOR_DARK, outline=HEX_LINE) + + return img + + +# --------------------------------------------------------------------------- +# SIDE FACE +# --------------------------------------------------------------------------- + +def make_side(): + """Create a side face with hex armor.""" + img = make_hex_armor_face() + draw = ImageDraw.Draw(img) + + # Horizontal panel seam at the midpoint + draw.line([(12, S // 2), (S - 12, S // 2)], + fill=SEAM_COLOR, width=2) + + # Corner bolts + bolt_inset = 24 + for bx, by in [(bolt_inset, bolt_inset), + (S - bolt_inset, bolt_inset), + (bolt_inset, S - bolt_inset), + (S - bolt_inset, S - bolt_inset)]: + draw_filled_circle(draw, bx, by, 10, ARMOR_LIGHT, + outline=EDGE_DARK) + draw_filled_circle(draw, bx, by, 5, RIVET_COLOR) + + return img + + +# --------------------------------------------------------------------------- +# TOP FACE — circular gauge +# --------------------------------------------------------------------------- + +def make_top(fill_fraction=0.0, energy_color=None, glow_color=None): + """Create top face with hex armor and a segmented charge bar.""" + img = make_hex_armor_face() + draw = ImageDraw.Draw(img) + cx, cy = S // 2, S // 2 + + # --- Charge bar: recessed rectangular panel --- + num_segments = 10 + bar_margin = 120 # inset from image edges + bar_height = 360 # total height of the bar area + bar_y = cy - bar_height // 2 + bar_x1 = bar_margin + bar_x2 = S - bar_margin + bar_w = bar_x2 - bar_x1 + seg_gap = 12 # gap between segments + seg_w = (bar_w - (num_segments + 1) * seg_gap) // num_segments + seg_inset = 20 # vertical inset within the panel + + # Recessed panel background + panel_pad = 24 + draw.rectangle([bar_x1 - panel_pad, bar_y - panel_pad, + bar_x2 + panel_pad, bar_y + bar_height + panel_pad], + fill=ARMOR_MID) + add_bevel_border(draw, + bar_x1 - panel_pad, bar_y - panel_pad, + bar_x2 + panel_pad, bar_y + bar_height + panel_pad, + EDGE_DARK, ARMOR_LIGHT, width=4) + + # Inner dark recess for the bar + draw.rectangle([bar_x1, bar_y, bar_x2, bar_y + bar_height], + fill=(15, 17, 22, 255)) + add_bevel_border(draw, bar_x1, bar_y, bar_x2, bar_y + bar_height, + EDGE_DARK, FRAME_DARK, width=3) + + # Segment slots + lit_count = int(fill_fraction * num_segments + 0.5) + for i in range(num_segments): + sx = bar_x1 + seg_gap + i * (seg_w + seg_gap) + sy = bar_y + seg_inset + sw = seg_w + sh = bar_height - 2 * seg_inset + + if i < lit_count and energy_color: + # Lit segment + draw.rectangle([sx, sy, sx + sw, sy + sh], + fill=energy_color) + # Brighter center stripe + stripe_inset = sw // 4 + bright = lerp_color(energy_color, glow_color, 0.4) + draw.rectangle([sx + stripe_inset, sy + 6, + sx + sw - stripe_inset, sy + sh - 6], + fill=bright) + # Glow bleed from lit segment + add_radial_glow(img, sx + sw // 2, cy, + sw, glow_color, intensity=0.15) + else: + # Dark empty slot + draw.rectangle([sx, sy, sx + sw, sy + sh], + fill=(20, 22, 28, 255)) + # Subtle inner border + draw.rectangle([sx + 2, sy + 2, sx + sw - 2, sy + sh - 2], + outline=(28, 30, 38, 255)) + + # Bolts at panel corners + bolt_positions = [ + (bar_x1 - panel_pad + 12, bar_y - panel_pad + 12), + (bar_x2 + panel_pad - 12, bar_y - panel_pad + 12), + (bar_x1 - panel_pad + 12, bar_y + bar_height + panel_pad - 12), + (bar_x2 + panel_pad - 12, bar_y + bar_height + panel_pad - 12), + ] + for bx, by in bolt_positions: + draw_filled_circle(draw, bx, by, 10, ARMOR_LIGHT, + outline=EDGE_DARK) + draw_filled_circle(draw, bx, by, 5, RIVET_COLOR) + + # Corner bolts on the face + bolt_inset = 40 + for bx, by in [(bolt_inset, bolt_inset), + (S - bolt_inset, bolt_inset), + (bolt_inset, S - bolt_inset), + (S - bolt_inset, S - bolt_inset)]: + draw_filled_circle(draw, bx, by, 10, ARMOR_LIGHT, + outline=EDGE_DARK) + draw_filled_circle(draw, bx, by, 5, RIVET_COLOR) + + return img + + +# --------------------------------------------------------------------------- +# BOTTOM FACE — heavy base plate +# --------------------------------------------------------------------------- + +def make_bottom(): + """Create bottom face: hex armor with vent grille and mounting feet.""" + img = new_img(S, ARMOR_DARK) + draw = ImageDraw.Draw(img) + + # Outer border + add_border(draw, S, EDGE_DARK, width=6) + add_bevel_border(draw, 6, 6, S - 7, S - 7, ARMOR_LIGHT, EDGE_DARK, + width=3) + + # Background hex pattern + for cx, cy in HEX_CENTERS: + verts = _hex_vertices(cx, cy, HEX_RADIUS - 1) + draw.polygon(verts, fill=ARMOR_DARK, outline=HEX_LINE) + + center = S // 2 + + # Central vent grille + grille_size = 400 + g1 = center - grille_size // 2 + g2 = center + grille_size // 2 + draw.rectangle([g1, g1, g2, g2], fill=ARMOR_MID, outline=ARMOR_LIGHT) + add_bevel_border(draw, g1, g1, g2, g2, ARMOR_LIGHT, EDGE_DARK, width=3) + + # Vent slots + for vy in range(g1 + 30, g2 - 20, 24): + draw.rectangle([g1 + 20, vy, g2 - 20, vy + 10], fill=EDGE_DARK) + draw.rectangle([g1 + 22, vy + 2, g2 - 22, vy + 8], + fill=(15, 17, 22, 255)) + + # Mounting feet in corners + foot_size = 80 + foot_inset = 60 + for fx, fy in [(foot_inset, foot_inset), + (S - foot_inset - foot_size, foot_inset), + (foot_inset, S - foot_inset - foot_size), + (S - foot_inset - foot_size, + S - foot_inset - foot_size)]: + draw.rectangle([fx, fy, fx + foot_size, fy + foot_size], + fill=ARMOR_LIGHT, outline=EDGE_DARK) + add_bevel_border(draw, fx, fy, fx + foot_size, fy + foot_size, + FRAME_LIGHT, EDGE_DARK, width=2) + fcx = fx + foot_size // 2 + fcy = fy + foot_size // 2 + draw_filled_circle(draw, fcx, fcy, 14, ARMOR_MID, + outline=EDGE_DARK) + draw_filled_circle(draw, fcx, fcy, 7, RIVET_COLOR) + + # Cross seams + draw.line([(center, foot_inset + foot_size), (center, g1)], + fill=SEAM_COLOR, width=3) + draw.line([(center, g2), (center, S - foot_inset - foot_size)], + fill=SEAM_COLOR, width=3) + draw.line([(foot_inset + foot_size, center), (g1, center)], + fill=SEAM_COLOR, width=3) + draw.line([(g2, center), (S - foot_inset - foot_size, center)], + fill=SEAM_COLOR, width=3) + + return img + + +# --------------------------------------------------------------------------- +# MAIN +# --------------------------------------------------------------------------- + +def main(): + textures = {} + + # Top face for each charge state + for state, (frac, color, glow) in CHARGE_STATES.items(): + suffix = "" if state == "empty" else f"_{state}" + textures[f"small_battery{suffix}"] = make_top(frac, color, glow) + + # Side and bottom (shared across all charge states) + textures["small_battery_side"] = make_side() + textures["small_battery_bottom"] = make_bottom() + + save_textures(textures) + + +if __name__ == "__main__": + main() diff --git a/scripts/texture_lib.py b/scripts/texture_lib.py new file mode 100644 index 0000000..306220b --- /dev/null +++ b/scripts/texture_lib.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Shared texture generation utilities for Atlas block textures. + +Provides common drawing primitives, color blending, and pattern generators +that work at any resolution (16 through 1024). +""" + +import os +import math +from PIL import Image, ImageDraw + +# Default output directory for generated textures +OUTPUT_DIR = os.path.join( + os.path.dirname(__file__), "..", + "src", "main", "resources", "atlas", "resourcepack", + "assets", "minecraft", "textures", "block", "custom" +) + + +# --------------------------------------------------------------------------- +# Image creation +# --------------------------------------------------------------------------- + +def new_img(size, fill=(0, 0, 0, 255)): + """Create a new RGBA image of the given size with a solid fill.""" + return Image.new("RGBA", (size, size), fill) + + +# --------------------------------------------------------------------------- +# Color utilities +# --------------------------------------------------------------------------- + +def lerp_color(c1, c2, t): + """Linearly interpolate between two RGBA colors. t in [0, 1].""" + t = max(0.0, min(1.0, t)) + return tuple(int(a + (b - a) * t) for a, b in zip(c1, c2)) + + +def blend_over(base, overlay, alpha): + """Blend overlay color onto base with a given alpha (0.0-1.0).""" + a = max(0.0, min(1.0, alpha)) + return ( + int(base[0] * (1 - a) + overlay[0] * a), + int(base[1] * (1 - a) + overlay[1] * a), + int(base[2] * (1 - a) + overlay[2] * a), + 255, + ) + + +# --------------------------------------------------------------------------- +# Basic drawing helpers +# --------------------------------------------------------------------------- + +def add_border(draw, size, color=(35, 40, 50, 255), width=1): + """Draw a rectangular border around the full image.""" + for i in range(width): + draw.rectangle([i, i, size - 1 - i, size - 1 - i], outline=color) + + +def add_rivets(img, positions, color=(139, 149, 165, 255), rivet_size=1): + """Place square rivet dots at the given (x, y) positions.""" + for x, y in positions: + for dx in range(rivet_size): + for dy in range(rivet_size): + if 0 <= x + dx < img.width and 0 <= y + dy < img.height: + img.putpixel((x + dx, y + dy), color) + + +def fill_rect(img, x1, y1, x2, y2, color): + """Fill a rectangle on the image (inclusive coords).""" + draw = ImageDraw.Draw(img) + draw.rectangle([x1, y1, x2, y2], fill=color) + + +# --------------------------------------------------------------------------- +# Glow effects +# --------------------------------------------------------------------------- + +def add_glow_ring(img, cx, cy, r_inner, r_outer, glow_color): + """Add a radial glow ring that fades from glow_color to transparent.""" + for x in range(max(0, int(cx - r_outer - 1)), min(img.width, int(cx + r_outer + 2))): + for y in range(max(0, int(cy - r_outer - 1)), min(img.height, int(cy + r_outer + 2))): + d = math.sqrt((x - cx) ** 2 + (y - cy) ** 2) + if r_inner <= d <= r_outer: + t = 1.0 - (d - r_inner) / (r_outer - r_inner) + a = (180 * t) / 255.0 + base = img.getpixel((x, y)) + img.putpixel((x, y), blend_over(base, glow_color, a)) + + +def add_glow_to_edges(img, glow_color, intensity=0.35, depth=3): + """Add a glow effect along the edges of the image.""" + s = img.width + for x in range(s): + for y in range(s): + dist_edge = min(x, y, s - 1 - x, s - 1 - y) + if dist_edge < depth: + t = intensity * (1.0 - dist_edge / depth) + base = img.getpixel((x, y)) + img.putpixel((x, y), blend_over(base, glow_color, t)) + + +def add_radial_glow(img, cx, cy, radius, glow_color, intensity=0.6): + """Add a soft radial glow emanating from center point.""" + for x in range(max(0, int(cx - radius)), min(img.width, int(cx + radius + 1))): + for y in range(max(0, int(cy - radius)), min(img.height, int(cy + radius + 1))): + d = math.sqrt((x - cx) ** 2 + (y - cy) ** 2) + if d <= radius: + t = intensity * (1.0 - d / radius) ** 1.5 + base = img.getpixel((x, y)) + img.putpixel((x, y), blend_over(base, glow_color, t)) + + +# --------------------------------------------------------------------------- +# Hexagonal honeycomb pattern +# --------------------------------------------------------------------------- + +def draw_hex_grid(img, hex_radius, line_color, fill_color=None, inset=0, + mask_fn=None): + """Draw a hexagonal honeycomb grid across the image. + + Args: + img: Target PIL image. + hex_radius: Radius of each hexagon (center to vertex). + line_color: Color for hex outlines. + fill_color: Optional fill color for hex interiors (None = transparent). + inset: Pixel inset from image edges to start the grid. + mask_fn: Optional callable(cx, cy) -> bool. If provided, only draw + hexagons where mask_fn returns True. + """ + s = img.width + draw = ImageDraw.Draw(img) + + # Flat-top hex geometry + hex_w = hex_radius * 2 + hex_h = hex_radius * math.sqrt(3) + col_step = hex_w * 0.75 + row_step = hex_h + + cols = int((s - 2 * inset) / col_step) + 2 + rows = int((s - 2 * inset) / row_step) + 2 + + for col in range(-1, cols + 1): + for row in range(-1, rows + 1): + cx = inset + col * col_step + cy = inset + row * row_step + (col % 2) * (hex_h / 2) + + if mask_fn and not mask_fn(cx, cy): + continue + + vertices = [] + for i in range(6): + angle = math.radians(60 * i) + vx = cx + hex_radius * math.cos(angle) + vy = cy + hex_radius * math.sin(angle) + vertices.append((vx, vy)) + + if fill_color: + draw.polygon(vertices, fill=fill_color) + draw.polygon(vertices, outline=line_color) + + +def draw_hex_grid_lines_only(img, hex_radius, line_color, line_width=1, + inset=0, mask_fn=None): + """Draw only the lines of a hexagonal grid (no fill), with configurable width.""" + s = img.width + draw = ImageDraw.Draw(img) + + hex_w = hex_radius * 2 + hex_h = hex_radius * math.sqrt(3) + col_step = hex_w * 0.75 + row_step = hex_h + + cols = int((s - 2 * inset) / col_step) + 2 + rows = int((s - 2 * inset) / row_step) + 2 + + for col in range(-1, cols + 1): + for row in range(-1, rows + 1): + cx = inset + col * col_step + cy = inset + row * row_step + (col % 2) * (hex_h / 2) + + if mask_fn and not mask_fn(cx, cy): + continue + + vertices = [] + for i in range(6): + angle = math.radians(60 * i) + vx = cx + hex_radius * math.cos(angle) + vy = cy + hex_radius * math.sin(angle) + vertices.append((vx, vy)) + + for i in range(6): + draw.line([vertices[i], vertices[(i + 1) % 6]], + fill=line_color, width=line_width) + + +# --------------------------------------------------------------------------- +# Panel and seam drawing +# --------------------------------------------------------------------------- + +def add_panel_seam(draw, start, end, color, width=1): + """Draw a panel seam line.""" + draw.line([start, end], fill=color, width=width) + + +def add_glowing_seam(img, start, end, seam_color, glow_color, seam_width=2, + glow_width=6, intensity=0.3): + """Draw a panel seam with a glow effect around it.""" + draw = ImageDraw.Draw(img) + # Draw glow passes (wider, semi-transparent) + for w in range(glow_width, seam_width, -1): + t = intensity * (1.0 - (w - seam_width) / (glow_width - seam_width)) + # We can't do per-pixel blending with draw.line easily, + # so we draw the seam at the desired color with diminishing opacity + gc = (*glow_color[:3], int(255 * t)) + draw.line([start, end], fill=gc, width=w) + # Draw the solid seam on top + draw.line([start, end], fill=seam_color, width=seam_width) + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +def save_textures(textures, output_dir=None): + """Save a dict of {name: Image} to the output directory as PNGs.""" + out = output_dir or OUTPUT_DIR + os.makedirs(out, exist_ok=True) + for name, img in textures.items(): + path = os.path.join(out, f"{name}.png") + img.save(path) + print(f" Created {name}.png ({img.size[0]}x{img.size[1]})") + print(f"\nGenerated {len(textures)} textures in {out}") diff --git a/src/main/kotlin/com/coderjoe/atlas/power/block/SmallBattery.kt b/src/main/kotlin/com/coderjoe/atlas/power/block/SmallBattery.kt index 50cf784..e06826f 100644 --- a/src/main/kotlin/com/coderjoe/atlas/power/block/SmallBattery.kt +++ b/src/main/kotlin/com/coderjoe/atlas/power/block/SmallBattery.kt @@ -19,6 +19,7 @@ class SmallBattery(location: Location, facing: BlockFace) : PowerBlock(location, const val BLOCK_ID = "atlas:small_battery" const val BLOCK_ID_LOW = "atlas:small_battery_low" const val BLOCK_ID_MEDIUM = "atlas:small_battery_medium" + const val BLOCK_ID_HIGH = "atlas:small_battery_high" const val BLOCK_ID_FULL = "atlas:small_battery_full" val descriptor = @@ -27,7 +28,7 @@ class SmallBattery(location: Location, facing: BlockFace) : PowerBlock(location, displayName = "Small Battery", description = "Storage - holds up to 50 power", placementType = PlacementType.SIMPLE, - additionalBlockIds = listOf(BLOCK_ID_LOW, BLOCK_ID_MEDIUM, BLOCK_ID_FULL), + additionalBlockIds = listOf(BLOCK_ID_LOW, BLOCK_ID_MEDIUM, BLOCK_ID_HIGH, BLOCK_ID_FULL), constructor = { loc, facing -> SmallBattery(loc, facing) }, ) } @@ -37,9 +38,10 @@ class SmallBattery(location: Location, facing: BlockFace) : PowerBlock(location, private fun chargeLevel(): Int = when (currentPower) { 0 -> 0 - in 1..16 -> 1 - in 17..33 -> 2 - else -> 3 + in 1..12 -> 1 + in 13..25 -> 2 + in 26..37 -> 3 + else -> 4 } override fun getVisualStateBlockId(): String = @@ -47,6 +49,7 @@ class SmallBattery(location: Location, facing: BlockFace) : PowerBlock(location, 0 -> BLOCK_ID 1 -> BLOCK_ID_LOW 2 -> BLOCK_ID_MEDIUM + 3 -> BLOCK_ID_HIGH else -> BLOCK_ID_FULL } diff --git a/src/main/resources/atlas/configuration/small_battery.yml b/src/main/resources/atlas/configuration/small_battery.yml index 55cae26..88efb78 100644 --- a/src/main/resources/atlas/configuration/small_battery.yml +++ b/src/main/resources/atlas/configuration/small_battery.yml @@ -112,6 +112,45 @@ items#2: side: minecraft:block/custom/small_battery_side items#3: + atlas:small_battery_high: + material: paper + data: + item-name: "Small Battery" + model: minecraft:block/custom/small_battery_high + behavior: + type: block_item + block: + loot: + pools: + - rolls: 1 + entries: + - type: item + item: atlas:small_battery + settings: + hardness: 4.0 + resistance: 4.0 + is-suffocating: true + is-redstone-conductor: false + push-reaction: push_only + tags: ["minecraft:mineable/pickaxe"] + sounds: + break: minecraft:block.metal.break + step: minecraft:block.metal.step + place: minecraft:block.metal.place + hit: minecraft:block.metal.hit + fall: minecraft:block.metal.fall + state: + auto-state: solid + model: + path: minecraft:block/custom/small_battery_high + generation: + parent: minecraft:block/cube_bottom_top + textures: + top: minecraft:block/custom/small_battery_high + bottom: minecraft:block/custom/small_battery_bottom + side: minecraft:block/custom/small_battery_side + +items#4: atlas:small_battery_full: material: paper data: diff --git a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery.png b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery.png index 2978b53..7a4ed03 100644 Binary files a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery.png and b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery.png differ diff --git a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_bottom.png b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_bottom.png index c36861b..aaab881 100644 Binary files a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_bottom.png and b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_bottom.png differ diff --git a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_full.png b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_full.png index 3fbf57d..e268dda 100644 Binary files a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_full.png and b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_full.png differ diff --git a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_high.png b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_high.png new file mode 100644 index 0000000..37e54e1 Binary files /dev/null and b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_high.png differ diff --git a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_low.png b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_low.png index 8dd84df..47d99c9 100644 Binary files a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_low.png and b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_low.png differ diff --git a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_medium.png b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_medium.png index 145f97f..16dd8d5 100644 Binary files a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_medium.png and b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_medium.png differ diff --git a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_side.png b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_side.png index 0a54722..5615113 100644 Binary files a/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_side.png and b/src/main/resources/atlas/resourcepack/assets/minecraft/textures/block/custom/small_battery_side.png differ diff --git a/src/test/kotlin/com/coderjoe/atlas/AtlasPluginTest.kt b/src/test/kotlin/com/coderjoe/atlas/AtlasPluginTest.kt index 4ac588a..59249bd 100644 --- a/src/test/kotlin/com/coderjoe/atlas/AtlasPluginTest.kt +++ b/src/test/kotlin/com/coderjoe/atlas/AtlasPluginTest.kt @@ -28,9 +28,9 @@ class AtlasPluginTest { } @Test - fun `power system initializes with 19 block types`() { + fun `power system initializes with 20 block types`() { TestHelper.initPowerFactory() - assertEquals(19, PowerBlockFactory.getRegisteredBlockIds().size) + assertEquals(20, PowerBlockFactory.getRegisteredBlockIds().size) } @Test diff --git a/src/test/kotlin/com/coderjoe/atlas/power/PowerBlockInitializerTest.kt b/src/test/kotlin/com/coderjoe/atlas/power/PowerBlockInitializerTest.kt index c0ca2a3..98a28c1 100644 --- a/src/test/kotlin/com/coderjoe/atlas/power/PowerBlockInitializerTest.kt +++ b/src/test/kotlin/com/coderjoe/atlas/power/PowerBlockInitializerTest.kt @@ -30,7 +30,7 @@ class PowerBlockInitializerTest { // SmallSolarPanel: 2 (base + full) // SmallDrill: 1 - // SmallBattery: 4 (base + low + medium + full) + // SmallBattery: 5 (base + low + medium + high + full) // PowerCable: 1 // LavaGenerator: 2 (base + active) // AutoSmelter: 1 @@ -42,7 +42,7 @@ class PowerBlockInitializerTest { // SoftTouchDrill: 1 // ExperienceExtractor: 2 (base + active) // Total: 19 - assertEquals(19, ids.size) + assertEquals(20, ids.size) } @Test diff --git a/src/test/kotlin/com/coderjoe/atlas/power/PowerBlockLogicTest.kt b/src/test/kotlin/com/coderjoe/atlas/power/PowerBlockLogicTest.kt index fade5be..e7fb53c 100644 --- a/src/test/kotlin/com/coderjoe/atlas/power/PowerBlockLogicTest.kt +++ b/src/test/kotlin/com/coderjoe/atlas/power/PowerBlockLogicTest.kt @@ -206,10 +206,10 @@ class PowerBlockLogicTest { } @Test - fun `battery visual state low when power 1-16`() { + fun `battery visual state low when power 1-12`() { val battery = SmallBattery(TestHelper.createLocation(), BlockFace.NORTH) - for (p in 1..16) { + for (p in 1..12) { battery.currentPower = p assertEquals( "atlas:small_battery_low", @@ -220,10 +220,10 @@ class PowerBlockLogicTest { } @Test - fun `battery visual state medium when power 17-33`() { + fun `battery visual state medium when power 13-25`() { val battery = SmallBattery(TestHelper.createLocation(), BlockFace.NORTH) - for (p in 17..33) { + for (p in 13..25) { battery.currentPower = p assertEquals( "atlas:small_battery_medium", @@ -234,10 +234,24 @@ class PowerBlockLogicTest { } @Test - fun `battery visual state full when power 34-50`() { + fun `battery visual state high when power 26-37`() { val battery = SmallBattery(TestHelper.createLocation(), BlockFace.NORTH) - for (p in 34..50) { + for (p in 26..37) { + battery.currentPower = p + assertEquals( + "atlas:small_battery_high", + battery.getVisualStateBlockId(), + "Failed for power=$p", + ) + } + } + + @Test + fun `battery visual state full when power 38-50`() { + val battery = + SmallBattery(TestHelper.createLocation(), BlockFace.NORTH) + for (p in 38..50) { battery.currentPower = p assertEquals( "atlas:small_battery_full",