diff --git a/assets/snake.png b/assets/snake.png new file mode 100644 index 0000000..0cb81d4 Binary files /dev/null and b/assets/snake.png differ diff --git a/cspell.json b/cspell.json index 6db43fa..91864c6 100644 --- a/cspell.json +++ b/cspell.json @@ -107,6 +107,7 @@ "pagedown", "pageup", "perp", + "Pixi", "pmenu", "popdown", "predeploy", diff --git a/src/World.coffee b/src/World.coffee index 4d398fd..7d9816c 100644 --- a/src/World.coffee +++ b/src/World.coffee @@ -417,6 +417,10 @@ module.exports = class World x += w * Math.random() i += 0.1 + pixiUpdate: (stage, ticker)-> + for entity in @entities + entity.pixiUpdate?(stage, ticker) + draw: (ctx, view)-> # ctx.fillStyle = "#32C8FF" # {x, y} = view.toWorld({x: 0, y: 0}) diff --git a/src/entities/Snake.coffee b/src/entities/Snake.coffee new file mode 100644 index 0000000..17b6206 --- /dev/null +++ b/src/entities/Snake.coffee @@ -0,0 +1,274 @@ +Entity = require("./abstract/Entity.coffee") +{addEntityClass} = require("skele2d") +{distanceToLineSegment, closestPointOnLineSegment} = require("skele2d").helpers +PIXI = require("pixi.js") +TAU = Math.PI * 2 + + +snake_texture = PIXI.Texture.from('assets/snake.png') +snake_texture.rotate = PIXI.groupD8.MIRROR_HORIZONTAL + +module.exports = class Snake extends Entity + addEntityClass(@) + constructor: -> + super() + # relying on key order, so points & segments must not be named with simple numbers, + # since numeric keys are sorted before other keys + @structure.addPoint("head") + previous_part_name = "head" + for i in [1...20] + part_name = "part_#{i}" + previous_part_name = @structure.addSegment( + from: previous_part_name + to: part_name + name: part_name + length: 50 + width: 40 + ) + + parts_list = Object.values(@structure.points).filter((part)=> part.name.match(/head|part/)) + for part, part_index in parts_list + part.radius = 50 #- part_index*0.1 + part.vx = 0 + part.vy = 0 + + @structure.points.head.radius *= 1.2 + + @bbox_padding = 150 + return + + toJSON: -> + def = {} + def[k] = v for k, v of @ when not k.startsWith("$_") + return def + + initLayout: -> + for segment_name, segment of @structure.segments + segment.b.x = segment.a.x + segment.length + return + + step: (world)-> + parts_list = Object.values(@structure.points).filter((part)=> part.name.match(/head|part/)) + + # stop at end of the world + for part in parts_list + if part.y + @y > 400 + return + + # reset/init + for part in parts_list + part.fx = 0 + part.fy = 0 + + # move + collision = (point)=> world.collision(@toWorld(point), types: (entity)=> + entity.constructor.name not in ["Water", "Snake"] + ) + t = performance.now()/1000 + for part, part_index in parts_list + # part.x += part.vx + # part.y += part.vy + hit = collision(part) + if hit + part.vx = 0 + part.vy = 0 + # Project the part's position back to the surface of the ground. + # This is done by finding the closest point on the polygon's edges. + closest_distance = Infinity + closest_segment = null + part_world = @toWorld(part) + part_in_hit_space = hit.fromWorld(part_world) + for segment_name, segment of hit.structure.segments + dist = distanceToLineSegment(part_in_hit_space, segment.a, segment.b) + if dist < closest_distance and Math.hypot(segment.a.x - segment.b.x, segment.a.y - segment.b.y) > 0.1 + closest_distance = dist + closest_segment = segment + if closest_segment + closest_point_in_hit_space = closestPointOnLineSegment(part_in_hit_space, closest_segment.a, closest_segment.b) + closest_point_world = hit.toWorld(closest_point_in_hit_space) + closest_point_local = @fromWorld(closest_point_world) + part.x = closest_point_local.x + part.y = closest_point_local.y + else + part.vy += 0.5 + part.vx *= 0.99 + part.vy *= 0.99 + # @structure.stepLayout({gravity: 0.005, collision}) + # @structure.stepLayout() for [0..10] + # @structure.stepLayout({collision}) for [0..4] + part.x += part.vx + part.y += part.vy + + # angular constraint pivoting on this part + relative_angle = (Math.sin(Math.sin(t)*Math.PI/4) - 0.5) * Math.PI/parts_list.length/2 + part.relative_angle = relative_angle + prev_part = parts_list[part_index-1] + next_part = parts_list[part_index+1] + if prev_part and next_part + @accumulate_angular_constraint_forces(prev_part, next_part, part, relative_angle) + + # apply forces + for part in parts_list + part.vx += part.fx + part.vy += part.fy + part.x += part.fx + part.y += part.fy + + # Interact with water + for part in parts_list + water = world.collision(@toWorld(part), types: (entity)=> + entity.constructor.name is "Water" + ) + too_far_under_water = water and world.collision(@toWorld({x: part.x, y: part.y - part.radius}), types: (entity)=> + entity.constructor.name is "Water" + ) + if water and not too_far_under_water + # Make ripples in water + water.makeWaves(@toWorld(part), part.radius, part.vy/2) + # Skip off water (as if this will ever matter) + if 4 > part.vy > 2 and Math.abs(part.vx) > 0.4 + part.vy *= -0.3 + # Slow down in water, and buoy + if water + part.vx -= part.vx * 0.1 + part.vy -= part.vy * 0.1 + part.vy -= 0.45 + + # constrain distances + for i in [0...4] + for segment_name, segment of @structure.segments + delta_x = segment.a.x - segment.b.x + delta_y = segment.a.y - segment.b.y + delta_length = Math.sqrt(delta_x * delta_x + delta_y * delta_y) + diff = (delta_length - segment.length) / delta_length + if isFinite(diff) + segment.a.x -= delta_x * 0.5 * diff + segment.a.y -= delta_y * 0.5 * diff + segment.b.x += delta_x * 0.5 * diff + segment.b.y += delta_y * 0.5 * diff + segment.a.vx -= delta_x * 0.5 * diff + segment.a.vy -= delta_y * 0.5 * diff + segment.b.vx += delta_x * 0.5 * diff + segment.b.vy += delta_y * 0.5 * diff + else + console.warn("diff is not finite, for Snake distance constraint") + # self-collision + for part, part_index in parts_list + for other_part, other_part_index in parts_list #when part_index isnt other_part_index + if Math.abs(part_index - other_part_index) < 3 + continue + delta_x = part.x - other_part.x + delta_y = part.y - other_part.y + delta_length = Math.sqrt(delta_x * delta_x + delta_y * delta_y) + target_min_length = part.radius + other_part.radius + if delta_length < target_min_length + diff = (delta_length - target_min_length) / delta_length + if isFinite(diff) + part.x -= delta_x * 0.5 * diff + part.y -= delta_y * 0.5 * diff + other_part.x += delta_x * 0.5 * diff + other_part.y += delta_y * 0.5 * diff + part.vx -= delta_x * 0.5 * diff + part.vy -= delta_y * 0.5 * diff + other_part.vx += delta_x * 0.5 * diff + other_part.vy += delta_y * 0.5 * diff + else + console.warn("diff is not finite, for Snake self-collision constraint") + + return + + accumulate_angular_constraint_forces: (a, b, pivot, relative_angle)-> + angle_a = Math.atan2(a.y - b.y, a.x - b.x) + angle_b = Math.atan2(pivot.y - b.y, pivot.x - b.x) + angle_diff = (angle_a - angle_b) - relative_angle + + # angle_diff *= 0.9 + distance = Math.hypot(a.x - b.x, a.y - b.y) + # distance_a = Math.hypot(a.x - pivot.x, a.y - pivot.y) + # distance_b = Math.hypot(b.x - pivot.x, b.y - pivot.y) + # angle_diff /= Math.max(1, (distance / 5) ** 2.4) + + old_a = {x: a.x, y: a.y} + old_b = {x: b.x, y: b.y} + + # Rotate around pivot. + rot_matrix = [[Math.cos(angle_diff), Math.sin(angle_diff)], [-Math.sin(angle_diff), Math.cos(angle_diff)]] + rot_matrix_inverse = [[Math.cos(-angle_diff), Math.sin(-angle_diff)], [-Math.sin(-angle_diff), Math.cos(-angle_diff)]] + for point in [a, b] + # Translate and rotate. + [point.x, point.y] = [point.x, point.y].map((value, index) => + (if point is a then rot_matrix else rot_matrix_inverse)[index][0] * (point.x - pivot.x) + + (if point is a then rot_matrix else rot_matrix_inverse)[index][1] * (point.y - pivot.y) + ) + # Translate back. + point.x += pivot.x + point.y += pivot.y + + f = 0.5 + # using individual distances can cause spinning (overall angular momentum from nothing) + # f_a = f / Math.max(1, Math.max(0, distance_a - 3) ** 1) + # f_b = f / Math.max(1, Math.max(0, distance_b - 3) ** 1) + # using the combined distance conserves overall angular momentum, + # to say nothing of the physicality of the rest of this system + # but it's a clear difference in zero gravity + f_a = f / Math.max(1, Math.max(0, distance - 6) ** 1) + f_b = f / Math.max(1, Math.max(0, distance - 6) ** 1) + + # Turn difference in position into velocity. + a.fx += (a.x - old_a.x) * f_a + a.fy += (a.y - old_a.y) * f_a + b.fx += (b.x - old_b.x) * f_b + b.fy += (b.y - old_b.y) * f_b + + # Opposite force on pivot. + pivot.fx -= (a.x - old_a.x) * f_a + pivot.fy -= (a.y - old_a.y) * f_a + pivot.fx -= (b.x - old_b.x) * f_b + pivot.fy -= (b.y - old_b.y) * f_b + + # Restore old position. + a.x = old_a.x + a.y = old_a.y + b.x = old_b.x + b.y = old_b.y + + return + + destroy: -> + @$_container?.destroy() + @$_container = null + if @$_ticker + @$_ticker.remove(@$_tick) + @$_ticker = null + @$_tick = null + return + + pixiUpdate: (stage, ticker)-> + if @$_container + return + + rope_points = [] + + for point_name, point of @structure.points + rope_points.push(new PIXI.Point(point.x, point.y)) + + strip = new PIXI.SimpleRope(snake_texture, rope_points) + + @$_container = new PIXI.Container() + @$_container.x = @x + @$_container.y = @y + + stage.addChild(@$_container) + + @$_container.addChild(strip) + + @$_ticker = ticker + @$_ticker.add @$_tick = (delta)=> + @$_container.x = @x + @$_container.y = @y + for part, i in Object.values(@structure.points) + rope_points[i].x = part.x + rope_points[i].y = part.y + + return + return diff --git a/src/main.coffee b/src/main.coffee index 5570080..2ba6b29 100644 --- a/src/main.coffee +++ b/src/main.coffee @@ -4,6 +4,7 @@ Math.seedrandom("A world") {View, Mouse, Editor, Entity, Terrain} = require "skele2d" Stats = require "stats.js" {gui, update_property_inspector, configure_property_inspector} = require "./dev-ui.coffee" +PIXI = require "pixi.js" World = require "./World.coffee" keyboard = require "./keyboard.coffee" sort_entities = require "./sort-entities.coffee" @@ -14,6 +15,7 @@ require "./arrow-test.coffee" require "./entities/GeneticPlant.coffee" require "./entities/CactusTree.coffee" require "./entities/Caterpillar.coffee" +require "./entities/Snake.coffee" SavannaGrass = require "./entities/terrain/SavannaGrass.coffee" require "./entities/terrain/LushGrass.coffee" require "./entities/terrain/Rock.coffee" @@ -54,7 +56,26 @@ terrain.generate() bottom_of_world = terrain.toWorld(terrain.structure.bbox_max).y +bg_canvas = document.createElement("canvas") +bg_canvas.style.position = "absolute" +bg_canvas.style.top = "0" +bg_canvas.style.left = "0" +document.body.appendChild(bg_canvas) +bg_ctx = bg_canvas.getContext("2d") + +app = new PIXI.Application(resizeTo: window, backgroundAlpha: 0) +document.body.appendChild(app.view) +app.view.style.position = "absolute" +app.view.style.top = "0" +app.view.style.left = "0" +# for PixiJS Devtools +globalThis.__PIXI_APP__ = app + + canvas = document.createElement("canvas") +canvas.style.position = "absolute" +canvas.style.top = "0" +canvas.style.left = "0" document.body.appendChild(canvas) ctx = canvas.getContext("2d") @@ -104,12 +125,20 @@ setInterval -> redraw = -> - world.drawBackground(ctx, view) + world.drawBackground(bg_ctx, view) ctx.save() ctx.translate(canvas.width / 2, canvas.height / 2) ctx.scale(view.scale, view.scale) ctx.translate(-view.center_x, -view.center_y) + # align PIXI scene to the CanvasRenderingContext2D scene + app.stage.position.x = -view.center_x * view.scale + canvas.width / 2 + app.stage.position.y = -view.center_y * view.scale + canvas.height / 2 + app.stage.scale.x = view.scale + app.stage.scale.y = view.scale + + # Note: this doesn't actually cause PIXI to redraw. + world.pixiUpdate(app.stage, app.ticker) world.draw(ctx, view) editor.draw(ctx, view) if editor.editing @@ -197,17 +226,74 @@ Object.defineProperty(window, "the_player", get: => ) # You can set a "watch" in the Firefox debugger to `window.do_a_redraw()` # and then see how entities are changed while stepping through simulation code. -# (This trick doesn't work in Chrome, as of 2023. The canvas doesn't update.) +# (In Chrome this doesn't work, the canvas doesn't update, as of 2023.) +# Note: this doesn't currently cause PIXI to redraw, only the CanvasRenderingContext2D. window.do_a_redraw = redraw -gamepad_start_prev = false +# Set up entity previews in entities bar for entities that use PIXI rendering. +# Skele2D works with CanvasRenderingContext2D, so we need to create a PIXI canvas +# and draw it to the preview canvas. +# This could probably reuse a PIXI.Renderer and PIXI.Container, but maybe not the main ones. +# It doesn't matter until there are a lot of entities. +Entity::draw = (ctx, view, world)-> + if @pixiUpdate and view.is_preview + # Create PIXI canvas for preview + @$_preview_pixi_renderer ?= new PIXI.Renderer( + width: view.width + height: view.height + backgroundAlpha: 0 + antialias: yes + resolution: 1 + ) + @$_preview_pixi_stage ?= new PIXI.Container() + @$_preview_pixi_stage.x = -view.center_x * view.scale + view.width / 2 + @$_preview_pixi_stage.y = -view.center_y * view.scale + view.height / 2 + @$_preview_pixi_stage.scale.x = view.scale + @$_preview_pixi_stage.scale.y = view.scale + + # Dummy ticker + @$_preview_pixi_ticker = new PIXI.Ticker() + @$_preview_pixi_ticker.autoStart = false + @$_preview_pixi_ticker.stop() + + @pixiUpdate(@$_preview_pixi_stage, @$_preview_pixi_ticker) + + @$_preview_pixi_renderer.render(@$_preview_pixi_stage) + + # Undo view transform since we're handling the transform with PIXI. + ctx.setTransform(1, 0, 0, 1, 0, 0) + + # Draw PIXI canvas to preview canvas + ctx.drawImage(@$_preview_pixi_renderer.view, 0, 0) + return + +# This is a temporary holdover until I make Skele2D call a destroy() method on entities. +# I can also probably find some cleaner patterns for cleaning up PIXI stuff. +# This is my first time using PIXI. +# Skele2D sets `destroyed` to true when you delete an entity in the editor. +# Hm, it doesn't when you undo/redo, though, so I have to handle that separately, using `old_entities_list`. +Object.defineProperty Entity::, "destroyed", + configurable: yes + get: -> + return @$_destroyed + set: (value)-> + @$_destroyed = value + if value + @destroy?() + return + stats = new Stats stats.showPanel(0) +gamepad_start_prev = false + terrain_optimized = false -do animate = -> +old_entities_list = [] + +# do animate = -> +app.ticker.add -> return if window.CRASHED show_stats = (try localStorage["tiamblia.show_stats"]) is "true" if show_stats @@ -215,7 +301,7 @@ do animate = -> else stats.dom.remove() stats.begin() - requestAnimationFrame(animate) + # requestAnimationFrame(animate) Math.seedrandom(performance.now()) unless gui._hidden @@ -264,8 +350,11 @@ do animate = -> canvas.width = innerWidth unless canvas.width is innerWidth canvas.height = innerHeight unless canvas.height is innerHeight + bg_canvas.width = innerWidth unless bg_canvas.width is innerWidth + bg_canvas.height = innerHeight unless bg_canvas.height is innerHeight ctx.clearRect(0, 0, canvas.width, canvas.height) + bg_ctx.clearRect(0, 0, bg_canvas.width, bg_canvas.height) # not necessary as long as it's opaque, but can avoid confusion in case of errors for gamepad in (try navigator.getGamepads()) ? [] when gamepad if gamepad.buttons[9].pressed and not gamepad_start_prev @@ -326,6 +415,14 @@ do animate = -> redraw() editor.updateGUI() + + # Destroy entities that were removed from the world. + # This handles undo/redo and delete, although I also have a destroyed setter which handles delete. + # TODO: make Skele2D call a destroy method on entities when they're removed from the world. + for entity in old_entities_list + if entity not in world.entities + entity.destroy?() + old_entities_list = [...world.entities] # So that the editor will give new random entities each time you pull one into the world # (given that some entities use seedrandom, and fix the seed)