Collapse the spiky/sticky/skewer box-variant assets into a single per-
instance `adhesion` property on any moveable block, chosen in the editor
via a dropdown next to the colour picker and stored in the .sav alongside
the class.
- spiky -> sticky: soft grab on every true edge (goopy shader)
- skewer -> gluey: rigid weld on every true edge (spiked outline)
- old trailing-face sticky is removed; its saves fold into new sticky
Move the spike geometry + drawGlueySprite into shaders.lua, fold drawing
and the adhesion flag into GenericMoveable, and delete the three
Generic{Sticky,Spiky,Skewer} classes and box_variants.lua. Room:createEntity
normalises legacy spiky_/skewer_/sticky_ class prefixes so old saves load,
and serialization emits `adhesion`. World/map previews and the movement
proxy follow the rename.
Backfill rooms/*.sav to the new format via tools/backfill_adhesion.lua
(41 objects across 8 files). Update tests to the two-mode model; the two
tests asserting the removed lateral-shear limitation are dropped and a
legacy-load migration test added. 27/27 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
313 lines
12 KiB
Lua
313 lines
12 KiB
Lua
wiggle = love.graphics.newShader[[
|
|
|
|
extern number seed;
|
|
extern number amount;
|
|
|
|
|
|
int grain = 10; //higher = less, lower = more
|
|
|
|
float rand(vec2 co){
|
|
return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453);
|
|
}
|
|
|
|
vec4 effect( vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) {
|
|
vec4 pixel_me = Texel(texture, texture_coords );
|
|
|
|
return Texel(texture, texture_coords + (rand(texture_coords * seed) - .5) * amount);
|
|
/*
|
|
int resolution = int(12 * 1 / zoom);
|
|
|
|
vec2 final_coords = floor((resolution * texture_coords) + 0.5) / resolution;
|
|
|
|
vec4 averaged_pixel = (Texel(texture, final_coords - (1 / resolution)) + Texel(texture, final_coords)) / 2 + (rand(final_coords * seed) / grain);
|
|
|
|
averaged_pixel.a = 1;
|
|
|
|
pixel_me = Texel(texture, final_coords);
|
|
|
|
return averaged_pixel;
|
|
*/
|
|
}
|
|
]]
|
|
|
|
glow = love.graphics.newShader[[
|
|
vec4 effect( vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) {
|
|
vec4 pixel_me = Texel(texture, texture_coords );
|
|
if (pixel_me.rgb != vec3(0,0,0))
|
|
return pixel_me;
|
|
|
|
int samples = 5;
|
|
float intensity = 60;
|
|
float radius = .1;
|
|
|
|
for (int i = 1; i < samples; i++ ) {
|
|
vec4 pixel_left = Texel(texture, texture_coords + vec2(-radius * i, 0));
|
|
vec4 pixel_right = Texel(texture, texture_coords + vec2(radius * i, 0));
|
|
vec4 pixel_top = Texel(texture, texture_coords + vec2(0, radius * i));
|
|
vec4 pixel_bottom = Texel(texture, texture_coords + vec2(0, -radius * i));
|
|
vec4 pixel_top_left = Texel(texture, texture_coords + vec2(-radius * i, radius * i));
|
|
vec4 pixel_top_right = Texel(texture, texture_coords + vec2(radius * i, radius * i));
|
|
vec4 pixel_bottom_left = Texel(texture, texture_coords + vec2(-radius * i, -radius * i));
|
|
vec4 pixel_bottom_right = Texel(texture, texture_coords + vec2(radius * i, -radius * i));
|
|
|
|
pixel_me += (pixel_left + pixel_right + pixel_top + pixel_bottom + pixel_bottom_right + pixel_bottom_left + pixel_top_right + pixel_top_left) / (intensity * i);
|
|
|
|
intensity -= 2.5;
|
|
|
|
}
|
|
return pixel_me;
|
|
}
|
|
]]
|
|
|
|
-- Sticky boxes reuse the authored box textures. This shader only wobbles their
|
|
-- sampling; the draw helper adds a tiny squash-and-stretch bounce around center.
|
|
goopShader = love.graphics.newShader[[
|
|
extern number goopTime;
|
|
extern vec2 sourceSize;
|
|
|
|
float goopHash(float value) {
|
|
return fract(sin(value * 91.3458) * 47453.5453);
|
|
}
|
|
|
|
vec4 effect(vec4 color, Image texture, vec2 textureCoords, vec2 screenCoords) {
|
|
float sourceY = textureCoords.y;
|
|
float pixelWidth = 1.0 / sourceSize.x;
|
|
float pixelHeight = 1.0 / sourceSize.y;
|
|
float column = floor(textureCoords.x * sourceSize.x / 2.0);
|
|
float seed = goopHash(column);
|
|
|
|
// Two gentle, out-of-phase waves make the surface feel soft and viscous.
|
|
// Both stay sub-pixel so the authored silhouette remains recognizable.
|
|
float horizontalWave = sin(goopTime * 1.7 + sourceY * 10.0 + seed * 6.2831);
|
|
float verticalWave = sin(goopTime * 1.3 + textureCoords.x * 12.0 + seed * 3.1415);
|
|
float sourceX = clamp(textureCoords.x + horizontalWave * pixelWidth * 0.45,
|
|
pixelWidth * 0.5, 1.0 - pixelWidth * 0.5);
|
|
float wobbledY = clamp(sourceY + verticalWave * pixelHeight * 0.28,
|
|
pixelHeight * 0.5, 1.0 - pixelHeight * 0.5);
|
|
|
|
return Texel(texture, vec2(sourceX, wobbledY)) * color;
|
|
}
|
|
]]
|
|
|
|
-- Wires are drawn as primitives rather than textured sprites, but LÖVE still
|
|
-- runs this shader over its default white texture. Inactive runs stay visibly
|
|
-- connected at half brightness; powered runs get a travelling neon shimmer,
|
|
-- occasional tube-like noise, and a sub-pixel bend rather than a global fade.
|
|
wireNeonShader = love.graphics.newShader[[
|
|
extern number wirePower;
|
|
extern number wireTime;
|
|
extern number wirePhase;
|
|
|
|
float wireHash(vec2 point) {
|
|
return fract(sin(dot(point, vec2(127.1, 311.7))) * 43758.5453123);
|
|
}
|
|
|
|
vec4 position(mat4 transformProjection, vec4 vertexPosition) {
|
|
if (wirePower < 0.5) return transformProjection * vertexPosition;
|
|
|
|
// Every segment sharing an endpoint receives the same offset, so corners
|
|
// stay connected while the live current gives the tube a gentle movement.
|
|
float wave = sin(vertexPosition.x * 0.085 + vertexPosition.y * 0.13 +
|
|
wireTime * 4.4 + wirePhase * 9.0);
|
|
float wobble = wave * 0.32;
|
|
vertexPosition.xy += vec2(wobble, -wobble * 0.55);
|
|
return transformProjection * vertexPosition;
|
|
}
|
|
|
|
vec4 effect(vec4 color, Image texture, vec2 textureCoords, vec2 screenCoords) {
|
|
if (wirePower < 0.5) return vec4(color.rgb * 0.50, color.a * 0.72);
|
|
|
|
float travel = dot(screenCoords, vec2(0.070, 0.043)) - wireTime * 6.8 + wirePhase * 13.0;
|
|
float shimmer = sin(travel) * 0.075 + sin(travel * 2.37 + 1.7) * 0.035;
|
|
vec2 noiseCell = floor(screenCoords * 0.34) + floor(wireTime * 15.0) * vec2(1.0, -1.0);
|
|
float noise = wireHash(noiseCell + wirePhase);
|
|
float flicker = max(0.0, noise - 0.82) * 0.32;
|
|
float brightness = 0.92 + shimmer + flicker;
|
|
float alpha = 0.84 + shimmer * 0.80 + flicker * 0.55;
|
|
return vec4(color.rgb * brightness, color.a * alpha);
|
|
}
|
|
]]
|
|
|
|
-- World renders a neighbouring room twice for the porous seam: once through
|
|
-- its own active channels and once through the current room's channels. This
|
|
-- shader only controls the alpha of that second, already-authored render, so
|
|
-- it never approximates the game's literal RGB colour treatment.
|
|
porousRoomBoundaryShader = love.graphics.newShader[[
|
|
extern number porousTime;
|
|
extern vec4 porousRoomRect;
|
|
extern vec2 porousDirection;
|
|
extern number porousDepth;
|
|
|
|
float porousWave(vec2 point) {
|
|
float broad = sin(point.x * 0.095 + point.y * 0.071 + porousTime * 1.45);
|
|
float fine = sin(point.x * 0.231 - point.y * 0.187 - porousTime * 2.20);
|
|
return broad * 0.105 + fine * 0.050;
|
|
}
|
|
|
|
vec4 effect(vec4 color, Image texture, vec2 textureCoords, vec2 screenCoords) {
|
|
vec4 alternateView = Texel(texture, textureCoords) * color;
|
|
vec2 local = screenCoords - porousRoomRect.xy;
|
|
vec2 size = porousRoomRect.zw;
|
|
if (local.x < 0.0 || local.y < 0.0 || local.x > size.x || local.y > size.y)
|
|
return vec4(0.0);
|
|
|
|
float depth;
|
|
if (porousDirection.x > 0.5) depth = local.x;
|
|
else if (porousDirection.x < -0.5) depth = size.x - local.x;
|
|
else if (porousDirection.y > 0.5) depth = local.y;
|
|
else depth = size.y - local.y;
|
|
|
|
float towardSeam = 1.0 - smoothstep(0.0, porousDepth, depth);
|
|
// Animated fingers soften the fifty-fifty transition across the visible
|
|
// overlap while preserving the pixels of the real alternate room render.
|
|
float mask = smoothstep(0.18, 0.82, towardSeam + porousWave(screenCoords));
|
|
alternateView.a *= mask;
|
|
return alternateView;
|
|
}
|
|
]]
|
|
|
|
function drawGoopySprite(sprite, x, y, scale)
|
|
local w, h = sprite:getWidth(), sprite:getHeight()
|
|
local time = love.timer.getTime()
|
|
local bounce = math.sin(time * 2.4 + w * .17 + h * .11) * .035
|
|
local scaleX = scale * (1 + bounce)
|
|
local scaleY = scale * (1 - bounce)
|
|
local drawX = x - w * (scaleX - scale) / 2
|
|
local drawY = y - h * (scaleY - scale) / 2
|
|
|
|
goopShader:send("goopTime", time)
|
|
goopShader:send("sourceSize", {w, h})
|
|
local previousShader = love.graphics.getShader()
|
|
love.graphics.setShader(goopShader)
|
|
love.graphics.draw(sprite, drawX, drawY, 0, scaleX, scaleY)
|
|
love.graphics.setShader(previousShader)
|
|
end
|
|
|
|
-- Gluey blocks draw teeth on every *true* edge of their collision shape: a cell
|
|
-- edge only gets spikes when no sibling cell sits on the other side of it, so an
|
|
-- L/notched shape spikes its real contour rather than its bounding box.
|
|
local glueyEdgeDirections = {
|
|
{x = 1, y = 0}, {x = -1, y = 0},
|
|
{x = 0, y = 1}, {x = 0, y = -1},
|
|
}
|
|
|
|
local function shapeHasCell(shape, x, y)
|
|
for _, cell in ipairs(shape) do
|
|
if cell.x == x and cell.y == y then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Draw two teeth on every exposed cell edge. The art remains synthetic: the
|
|
-- underlying box sprite and its collision shape are still the authored base.
|
|
function drawSpikes(cellShape, x, y, scale)
|
|
cellShape = cellShape or {{x = 1, y = 1}}
|
|
local cellSize = 16 * scale
|
|
local inset = scale
|
|
local reach = 3 * scale
|
|
local halfBase = 1.5 * scale
|
|
|
|
for _, cell in ipairs(cellShape) do
|
|
local left = x + (cell.x - 1) * cellSize
|
|
local top = y + (cell.y - 1) * cellSize
|
|
for _, direction in ipairs(glueyEdgeDirections) do
|
|
if not shapeHasCell(cellShape, cell.x + direction.x, cell.y + direction.y) then
|
|
for _, along in ipairs({5, 11}) do
|
|
if direction.x ~= 0 then
|
|
local edgeX = direction.x > 0 and left + cellSize - inset or left + inset
|
|
local centerY = top + along * scale
|
|
love.graphics.polygon("fill",
|
|
edgeX, centerY - halfBase,
|
|
edgeX + direction.x * reach, centerY,
|
|
edgeX, centerY + halfBase
|
|
)
|
|
else
|
|
local edgeY = direction.y > 0 and top + cellSize - inset or top + inset
|
|
local centerX = left + along * scale
|
|
love.graphics.polygon("fill",
|
|
centerX - halfBase, edgeY,
|
|
centerX, edgeY + direction.y * reach,
|
|
centerX + halfBase, edgeY
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function drawGlueySprite(sprite, cellShape, x, y, scale)
|
|
drawSpikes(cellShape, x, y, scale)
|
|
love.graphics.draw(sprite, x, y, 0, scale, scale)
|
|
end
|
|
|
|
-- Accessibility post-process. The puzzle's RGB channels are normally also
|
|
-- its visual language, so this runs only after the entire scene has been
|
|
-- composed. It leaves neutral UI/text alone and remaps saturated colours to
|
|
-- a palette whose red, green, and yellow accents remain distinct for people
|
|
-- with deuteranopia.
|
|
accessibilityColorShader = love.graphics.newShader[[
|
|
extern number accessibilityTime;
|
|
|
|
vec3 rgbToHsv(vec3 c) {
|
|
float maximum = max(c.r, max(c.g, c.b));
|
|
float minimum = min(c.r, min(c.g, c.b));
|
|
float delta = maximum - minimum;
|
|
float hue = 0.0;
|
|
|
|
if (delta > 0.0001) {
|
|
if (maximum == c.r) {
|
|
hue = mod((c.g - c.b) / delta, 6.0);
|
|
} else if (maximum == c.g) {
|
|
hue = ((c.b - c.r) / delta) + 2.0;
|
|
} else {
|
|
hue = ((c.r - c.g) / delta) + 4.0;
|
|
}
|
|
hue = hue / 6.0;
|
|
if (hue < 0.0) hue += 1.0;
|
|
}
|
|
|
|
float saturation = maximum <= 0.0 ? 0.0 : delta / maximum;
|
|
return vec3(hue, saturation, maximum);
|
|
}
|
|
|
|
vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) {
|
|
vec4 pixel = Texel(texture, texture_coords) * color;
|
|
vec3 hsv = rgbToHsv(pixel.rgb);
|
|
|
|
// Preserve greys, white (including additive RGB overlap), and near-black
|
|
// background detail. Only colourful puzzle elements are recoloured.
|
|
if (hsv.y < 0.22 || hsv.z < 0.06) return pixel;
|
|
|
|
// Keep every additive pair in its own bucket. In particular, magenta
|
|
// (red + blue) must not become the same colour as red, and cyan
|
|
// (green + blue) must not become the same colour as green.
|
|
vec3 replacement;
|
|
float pattern = 0.0;
|
|
// Advance in whole pixels so the texture stays crisp instead of swimming.
|
|
float ditherStep = floor(accessibilityTime * 1.5);
|
|
if (hsv.x < 0.055 || hsv.x >= 0.94) {
|
|
replacement = vec3(0.93, 0.18, 0.50); // red: pink
|
|
} else if (hsv.x < 0.19) {
|
|
replacement = vec3(1.00, 0.78, 0.08); // red + green: gold
|
|
vec2 cell = mod(floor(screen_coords + vec2(ditherStep, 0.0)), vec2(9.0));
|
|
pattern = length(cell - vec2(4.0)) < 1.6 ? 1.0 : 0.0; // dithered dots
|
|
} else if (hsv.x < 0.43) {
|
|
replacement = vec3(0.00, 0.72, 0.62); // green: turquoise
|
|
} else if (hsv.x < 0.58) {
|
|
replacement = vec3(0.12, 0.55, 0.96); // green + blue: sky blue
|
|
pattern = mod(floor(screen_coords.x + screen_coords.y + ditherStep), 10.0) < 2.0 ? 1.0 : 0.0; // dithered slashes
|
|
} else if (hsv.x < 0.77) {
|
|
replacement = vec3(0.38, 0.32, 0.96); // blue: indigo
|
|
} else {
|
|
replacement = vec3(0.72, 0.25, 0.92); // red + blue: purple
|
|
pattern = mod(floor((screen_coords.x + ditherStep) / 4.0) + floor(screen_coords.y / 4.0), 2.0); // dithered checker
|
|
}
|
|
|
|
// Secondary colours get a gently animated dither cue, so they are
|
|
// identifiable even when their hue is hard to perceive. Lighting and
|
|
// fades remain.
|
|
replacement = mix(replacement, vec3(1.0), pattern * 0.55);
|
|
return vec4(replacement * hsv.z, pixel.a);
|
|
}
|
|
]]
|