diff --git a/cubzh/gigax/gigax.json b/cubzh/gigax/gigax.json new file mode 100644 index 0000000..86d35e3 --- /dev/null +++ b/cubzh/gigax/gigax.json @@ -0,0 +1,5 @@ +{ + "contributors": [ + { "tristandeborde": 1.0 } + ] +} \ No newline at end of file diff --git a/cubzh/gigax/gigax.lua b/cubzh/gigax/gigax.lua new file mode 100644 index 0000000..17c0a55 --- /dev/null +++ b/cubzh/gigax/gigax.lua @@ -0,0 +1,7 @@ +--- This module allows you to integrate the Gigax library into your project. +--- The library is used to generated realistic NPCs, quests, and more into your game. +---@code +--- gigax = require("github.com/gigaxgames/integrations/cubzh/gigax") + +mod = {} + diff --git a/cubzh/map_gen.lua b/cubzh/map_gen.lua deleted file mode 100644 index 122ab3b..0000000 --- a/cubzh/map_gen.lua +++ /dev/null @@ -1,174 +0,0 @@ -Config = { - Items = { - "claire.torch", - "voxels.gate" - } -} - - --- The rest of the variables are unchanged -local BROWN = Color(139, 69, 19) -local CORRIDOR_LENGTH = 150 -local door = Shape(Items.voxels.gate) -local CORRIDOR_WIDTH = door.Width -local WALL_HEIGHT = 40 -local TORCH_SPACING = 40 - --- Constants -local NUMBER_OF_CORRIDORS = 2 -local corridors = {} -local doors = {} -local lastDoorIx = 2 - - -Client.OnStart = function() - -- Add player to game - World:AddChild(Player, true) - - -- Initialize the corridors and gates - for i = 1, NUMBER_OF_CORRIDORS do - corridors[i] = makeCorridor((i-1) * CORRIDOR_LENGTH, i) - end - - -- Set up the UI - ease = require "ease" - ui = require "uikit" - sfx = require "sfx" - controls = require "controls" - controls:setButtonIcon("action1", "⬆️") -end - -Pointer.Click = function(pointerEvent) - local impact = pointerEvent:CastRay(kMapAndItemsCollisionGroups) - local object = impact.Object - - print(object.isDoor) - if impact and impact.Distance < 500 then - if object and object.isDoor then - doorAction(object, "toggle") - if object.number == lastDoorIx then -- Only if the last door is clicked - -- Move the other corridor in front - advanceCorridor() - end - end - end -end - -function advanceCorridor() - local corridorToMoveIx = lastDoorIx % 2 + 1 - local corridorToMove = corridors[corridorToMoveIx] - corridorToMove.Position = Number3(corridorToMove.Position.X + (CORRIDOR_LENGTH * 2), 0, 0) - -- close the other door - doorAction(doors[corridorToMoveIx], "close") - -- Toggle the last door index for the next time - lastDoorIx = corridorToMoveIx -end - --- Function to create and set up the door -function createDoorAtEndOfCorridor(offset, ix) - local door = Shape(Items.voxels.gate) - door.Position = Number3(offset + CORRIDOR_LENGTH, door.Height/2, door.Width) - door.Rotation = Number3(0, math.pi / 2, 0) -- Adjusted to face the corridor - door.Pivot = { 0, door.Height * 0.5, door.Depth * 0.5 } - door.isDoor = true - door.number = ix -- If you have more doors, increment this number for each - door.closed = true - door.rotClosed = math.pi / 2 -- The rotation when the door is closed - doors[door.number] = door - --door.collisionGroups = {DOOR_COLLISION_GROUP} - World:AddChild(door) - return door -end - -function makeCorridor(offset, ix) - print("Creating corridor " .. ix) - local corridor = Object() - World:AddChild(corridor) - -- Add a door at the end of the corridor - doors[ix] = createDoorAtEndOfCorridor(offset, ix) - corridor:AddChild(doors [ix]) - - -- Create floor - local floor = MutableShape() - for x = offset, offset + CORRIDOR_LENGTH - 1 do - for z = 1, CORRIDOR_WIDTH - 2 do -- Subtracting 2 since we're building walls on both sides - floor:AddBlock(BROWN, x, 0, z) - end - end - corridor:AddChild(floor) - - -- Create walls - local leftWall = MutableShape() - local rightWall = MutableShape() - for x = offset, offset + CORRIDOR_LENGTH - 1 do - for y = 1, WALL_HEIGHT do - -- Left wall - leftWall:AddBlock(BROWN, x, y, 0) - - -- Right wall - rightWall:AddBlock(BROWN, x, y, CORRIDOR_WIDTH - 1) - end - end - corridor:AddChild(leftWall) - corridor:AddChild(rightWall) - - -- Add torches alongside the walls, spaced evenly - for x = offset, offset + CORRIDOR_LENGTH - 1, TORCH_SPACING do - -- Left wall torches - local leftTorch = addTorch(x, 10, 4) -- Position adjusted for the left wall - -- Right wall torches - local rightTorch = addTorch(x, 10, CORRIDOR_WIDTH - 5) -- Position adjusted for the right wall - corridor:AddChild(leftTorch) - corridor:AddChild(rightTorch) - end - - return corridor -end - -function addTorch(x, y, z) - local torch = Shape(Items.claire.torch) - torch.Position = Number3(x, y, z) -- Position adjusted for scale - return torch -end - --- door stuff - --- This function would be called when a monster is defeated -function openDoor(doorNumber) - local door = doors[doorNumber] - if door and door.isDoor and door.closed then - doorAction(door, "open") - end -end - --- Function to perform actions on doors -function doorAction(object, action) - print("Performing action on door nb " .. object.number .. ": " .. action) - if action == "toggle" then - object.closed = not object.closed - elseif action == "close" then - object.closed = true - elseif action == "open" then - object.closed = false - end - - -- Play sound effects based on the action - if object.closed then - print("closing door") - sfx("doorclose_1", object.Position, 0.5) - else - print("opening door") - sfx("dooropen_1", object.Position, 0.5) - end - - -- Set physics to trigger to allow for interaction, but not immediately solid - object.Physics = PhysicsMode.Trigger - object.colliders = 0 - - -- Set the door's rotation based on whether it's open or closed - if object.closed then - object.Rotation = { 0, object.rotClosed, 0 } - else - object.Rotation = { 0, object.rotClosed + math.pi * 0.5, 0 } - end -end \ No newline at end of file diff --git a/cubzh/scripts/hub_v3.lua b/cubzh/scripts/hub_v3.lua new file mode 100644 index 0000000..48c9cfc --- /dev/null +++ b/cubzh/scripts/hub_v3.lua @@ -0,0 +1,1962 @@ +Dev.DisplayColliders = false +local DEBUG_AMBIENCES = false +local DEBUG_ITEMS = false +local DEBUG_PET = false + +local SPAWN_POSITION = Number3(254, 80, 181) --315, 81, 138 --spawn point placed in world editor +local SPAWN_ROTATION = Number3(0, math.pi * 0.08, 0) +local TITLE_SCREEN_CAMERA_POSITION_IN_BLOCK = Number3(40, 20, 30) +local MAP_SCALE = 6.0 -- var because could be overriden when loading map +local GLIDER_BACKPACK = { + SCALE = 0.75, + ITEM_NAME = "voxels.glider_backpack", +} + +local TIME_TO_AVATAR_CTA = 60 * 4 -- seconds +local TIME_TO_FRIENDS_CTA = 60 * 7 -- seconds + +local TIME_CYCLE_DURATION = 480 -- 8 minutes +local DAWN_DURATION = 0.05 -- percentages +local DAY_DURATION = 0.55 +local DUSK_DURATION = 0.05 +local NIGHT_DURATION = 0.35 + +local TIME_TO_MID_DAY = DAWN_DURATION + DAY_DURATION * 0.5 +local TIME_TO_NIGHTFALL = DAWN_DURATION + DAY_DURATION + DUSK_DURATION * 0.1 +local TIME_TO_DAYBREAK = DAWN_DURATION * 0.1 +local HOUR_HAND_OFFSET = -0.5 + 2 * TIME_TO_MID_DAY +local MINUTE_HAND_OFFSET = 1 + +local MAP_COLLISION_GROUPS = CollisionGroups(1) +local MAP_COLLIDES_WITH_GROUPS = CollisionGroups() + +local PLAYER_COLLISION_GROUPS = CollisionGroups(2) +local PLAYER_COLLIDES_WITH_GROUPS = CollisionGroups(1, 3, 4, 5) -- map + items + buildings + barriers + +local ITEM_COLLISION_GROUPS = CollisionGroups(3) +local ITEM_COLLIDES_WITH_GROUPS = CollisionGroups(1, 3, 4) -- map + items + buildings + +local BUILDING_COLLISION_GROUPS = CollisionGroups(4) +local BUILDING_COLLIDES_WITH_GROUPS = CollisionGroups() + +local BARRIER_COLLISION_GROUPS = CollisionGroups(5) +local BARRIER_COLLIDES_WITH_GROUPS = CollisionGroups() + +local CAMERA_COLLIDES_WITH_GROUPS = CollisionGroups(1, 4) -- map + buildings + +local ITEM_BUILDING_AND_BARRIER_COLLISION_GROUPS = CollisionGroups(3, 4, 5) + +local DRAFT_COLLISION_GROUPS = CollisionGroups(6) + +local TRIGGER_AREA_SIZE = Number3(60, 30, 60) + +local LIGHT_COLOR = Color(244, 210, 87) +-- local MAX_PLAYER_DISTANCE = 20 +-- local MAX_PLAYER_DISTANCE_SQR = MAX_PLAYER_DISTANCE * MAX_PLAYER_DISTANCE +-- local TIME_TO_HATCH = 120 + +Client.OnStart = function() + dialog = require("dialog") + dialog:setMaxWidth(400) + multi = require("multi") + textbubbles = require("textbubbles") + skills = require("object_skills") + controls = require("controls") + ambience = require("ambience") + collectible = require("collectible") + -- SFX & VFX + particles = require("particles") + walkSFX = require("walk_sfx") + sfx = require("sfx") + wingTrail = require("wingtrail") + + require("social"):enablePlayerClickMenu() + + -- HUD + textbubbles.displayPlayerChatBubbles = true + controls:setButtonIcon("action1", "⬆️") + dialog:setMaxWidth(400) + + -- AMBIENCE + Clouds.Altitude = 60 * MAP_SCALE + + if not DEBUG_AMBIENCES then + ambienceCycle = ambience:startCycle({ + { + config = dawn, + duration = TIME_CYCLE_DURATION * DAWN_DURATION, + }, + { + config = day, + duration = TIME_CYCLE_DURATION * DAY_DURATION, + fadeIn = TIME_CYCLE_DURATION * DAWN_DURATION * 0.5, + fadeOut = TIME_CYCLE_DURATION * DUSK_DURATION * 0.5, + }, + { + config = dusk, + duration = TIME_CYCLE_DURATION * DUSK_DURATION, + }, + { + config = night, + duration = TIME_CYCLE_DURATION * NIGHT_DURATION, + fadeIn = TIME_CYCLE_DURATION * DUSK_DURATION * 0.5, + fadeOut = TIME_CYCLE_DURATION * DAWN_DURATION * 0.5, + }, + }, { + internalTick = false, + }) + end + + -- CONTROLS + -- Disabling controls until user is authenticated + Client.DirectionalPad = nil + Client.Action1 = nil + Client.Action1Release = nil + Pointer.Drag = nil + + -- CAMERA + -- Set camera for pre-authentication state (rotating while title screen is shown) + cameraDefaultFOV = Camera.FOV + Camera:SetModeFree() + Camera.Position = TITLE_SCREEN_CAMERA_POSITION_IN_BLOCK * MAP_SCALE + + Menu:OnAuthComplete(function() + Client.DirectionalPad = playerControls.directionalPad + Pointer.Drag = playerControls.pointerDrag + Client.Action1 = action1 + Client.Action1Release = action1Release + + initPlayer(Player) + dropPlayer(Player) + + addCollectibles() + addTimers() + + print(Player.Username .. " joined!") + end) + + mapEffects() + + -- SYNCED ACTIONS + multi:onAction("swingRight", function(sender) + sender:SwingRight() + end) + + multi:onAction("equipGlider", function(sender) + local s = bundle.Shape(GLIDER_BACKPACK.ITEM_NAME) + s.Scale = GLIDER_BACKPACK.SCALE + sender:EquipBackpack(s) + end) + LocalEvent:Listen(LocalEvent.Name.LocalAvatarUpdate, function() + multi:action("updateAvatar") + require("api").getAvatar(Player.Username, function(_, _) + -- TODO: retry on error? maybe it should be done within getAvatar + end) + end) + multi:onAction("updateAvatar", function(sender) + avatar:get(sender.Username, sender.Avatar) + end) + + LocalEvent:Listen(LocalEvent.Name.AvatarLoaded, function() + require("api").getAvatar(Player.Username, function(_, _) + -- TODO: retry on error? maybe it should be done within getAvatar + end) + end) + + -- called when receiving information for distant object that are not linked + multi.linkRequest = function(name) + if _helpers.stringStartsWith(name, "p_") then + local playerID = math.floor(tonumber(_helpers.stringRemovePrefix(name, "p_"))) + local p = Players[playerID] + if p ~= nil then + multi:unlink("g_" .. p.ID) + + playerControls:walk(p) + + multi:link(p, "p_" .. p.ID) + multi:link(p.Head, "ph_" .. p.ID) + if p.Parent == nil then + p:SetParent(World) + end + end + elseif _helpers.stringStartsWith(name, "g_") then -- glider + local playerID = math.floor(tonumber(_helpers.stringRemovePrefix(name, "g_"))) + local p = Players[playerID] + if p ~= nil then + multi:unlink("p_" .. p.ID) + multi:unlink("ph_" .. p.ID) + + local glider = playerControls:glide(p) + + multi:link(glider, "g_" .. p.ID) + end + end + end +end + +local SAVE_INTERVAL = 0.1 +local SAVE_AMOUNT = 10 +local savedPositions, savedRotations = {}, {} + +local unixMilli +local currentTime + +local isNight = false +local tInCycle +local t = 0 +local t20 = 0 +local n +Client.Tick = function(dt) + t20 = t20 + dt * 20 + + if not localPlayerShown then + t = t + dt -- * 0.2 + + local p = Number3(393, 36 + 120, 92) + + p.Y = p.Y + (1 + math.sin(t * 0.6)) * 3 + + local rx = math.sin(t * 0.5) * math.rad(2) + local ry = math.rad(-22) + math.sin(t * 0.2) * math.rad(7) + + Camera.Position = p + Camera.Rotation = Rotation(rx, ry, 0) + else + if Player.Position.Y < -200 then + dropPlayer(Player) + end + end + + unixMilli = Time.UnixMilli() / 1000.0 + currentTime = unixMilli % TIME_CYCLE_DURATION + + tInCycle = currentTime / TIME_CYCLE_DURATION + + if townhallHourHand ~= nil then + -- rotation 0 -> 9, -math.pi * 0.5 -> 12 + -- mid-day -> 35% of TIME_CYCLE_DURATION + -- 0% of TIME_CYCLE_DURATION = -70% of 12h + -- Rotation(math.pi * (-0.5 + 2 * 0.7 - 2 * 2 * currentTime / TIME_CYCLE_DURATION), 0, 0) + townhallHourHand.LocalRotation = Rotation(math.pi * (HOUR_HAND_OFFSET - 4 * tInCycle), 0, 0) + end + + if townhallMinuteHand ~= nil then + -- rotation 0 -> 12 + -- Rotation(math.pi * (2 * 0.7 - 2 * 2 * 12 currentTime / TIME_CYCLE_DURATION), 0, 0) + townhallMinuteHand.LocalRotation = Rotation(math.pi * (MINUTE_HAND_OFFSET - 48 * tInCycle), 0, 0) + end + + if isNight then + if tInCycle > TIME_TO_DAYBREAK and tInCycle < TIME_TO_NIGHTFALL then + isNight = false + LocalEvent:Send("Day") + end + else + if tInCycle > TIME_TO_NIGHTFALL or tInCycle < TIME_TO_DAYBREAK then + isNight = true + LocalEvent:Send("Night") + end + end + + if not DEBUG_AMBIENCES then + ambienceCycle:setTime(currentTime) + end + + if Player.pet ~= nil then + pet:followPlayer() + if pet.level < 0 and pet.remaining > 0 then + pet:updateHatchTimer() + end + end + + n = (1 + math.sin(t20)) * 0.5 * 0.05 + for _, speaker in ipairs(speakers) do + speaker.Scale = speaker.originalScale + n + end +end + +Client.OnPlayerJoin = function(p) + if p == Player then + updateSync() -- Syncing for other players + return + end + initPlayer(p) + dropPlayer(p) + print(p.Username .. " joined!") +end + +Client.OnPlayerLeave = function(p) + multi:unlink("g_" .. p.ID) + multi:unlink("ph_" .. p.ID) + multi:unlink("p_" .. p.ID) + + if p ~= Player then + print(p.Username .. " just left!") + skills.removeStepClimbing(p) + walkSFX:unregister(p) + p:RemoveFromParent() + end +end + +function setupBuilding(obj) + obj.CollisionGroups = BUILDING_COLLISION_GROUPS + obj.CollidesWithGroups = BUILDING_COLLIDES_WITH_GROUPS + obj.InnerTransparentFaces = false +end + +speakers = {} +Client.OnWorldObjectLoad = function(obj) + ease = require("ease") + hierarchyactions = require("hierarchyactions") + toast = require("ui_toast") + bundle = require("bundle") + avatar = require("avatar") + + if obj.Name == "voxels.windmill" then + setupBuilding(obj) + obj.Wheel.Physics = PhysicsMode.Disabled + obj.Wheel.Tick = function(self, dt) + self:RotateLocal(-dt * 0.25, 0, 0) + end + elseif obj.Name == "voxels.drink_truck" then + hierarchyactions:applyToDescendants(obj, { includeRoot = true }, function(o) + setupBuilding(o) + end) + local lamps = obj:GetChild(8) + lamps.IsUnlit = false + + local l = Light() + l:SetParent(lamps) + l.LocalPosition.X = 10 + l.LocalPosition.Z = 30 + l.Color = LIGHT_COLOR + l.Hardness = 0.7 + l.On = false + + LocalEvent:Listen("Night", function(_) + lamps.IsUnlit = true + l.On = true + end) + LocalEvent:Listen("Day", function(_) + lamps.IsUnlit = false + l.On = false + end) + elseif obj.Name == "voxels.home_1" then + setupBuilding(obj) + elseif obj.Name == "voxels.city_lamp" then + obj.Shadow = true + local light = obj:GetChild(1) + light.IsUnlit = false + + local l = Light() + l:SetParent(light) + l.Color = LIGHT_COLOR + l.Hardness = 0.7 + l.On = false + + LocalEvent:Listen("Night", function(_) + light.IsUnlit = true + l.On = true + end) + LocalEvent:Listen("Day", function(_) + light.IsUnlit = false + l.On = false + end) + elseif obj.Name == "voxels.simple_lighthouse" then + setupBuilding(obj) + lightFire = obj:GetChild(1) + lightFire.IsUnlit = true + lightFire.IsHidden = true + lightRay = obj:GetChild(2) + lightRay.Physics = PhysicsMode.Disabled + lightRay.Scale.X = 10 + lightRay.IsUnlit = true + lightRay.Palette[1].Color.A = 20 + lightRay.IsHidden = true + -- LocalEvent:Listen("Night", function(_) + -- --lightFire.IsHidden = not data.isNight + -- --lightRay.IsHidden = not data.isNight + -- end) + elseif obj.Name == "voxels.townhall" then + setupBuilding(obj) + + townhallHourHand = obj.Hour + townhallHourHand.Pivot = { 0.5, 0.5, 0.5 } + + townhallMinuteHand = obj.Minute + townhallMinuteHand.Pivot = { 0.5, 0.5, 0.5 } + elseif obj.Name == "voxels.water_fountain" then + local w = obj:GetChild(1) -- water + w.Physics = PhysicsMode.Disabled + w.InnerTransparentFaces = false + local t1 = 0 + w.Tick = function(self, dt) + t1 = t1 + dt + self.Scale.Y = 1 + (math.sin(t1) * 0.05) + end + + local c = obj:GetChild(2) --floating cube + c.Physics = PhysicsMode.Disabled + + local collider = c:Copy() + collider.IsHidden = true + collider.CollisionGroups = ITEM_COLLISION_GROUPS + collider.CollidesWithGroups = ITEM_COLLIDES_WITH_GROUPS + collider.Physics = PhysicsMode.Static + collider:SetParent(obj) + collider.LocalPosition = c.LocalPosition + { 0, 5, 0 } + collider.Rotation = Rotation(0, 0, 0) + + local originY = c.LocalPosition.Y + 5 + local t2 = 0 + c.Tick = function(self, dt) + t2 = t2 + dt * 2 + self.LocalPosition.Y = originY + 1 + math.sin(t2) * 0.5 * 4 + self:RotateLocal(0, dt * 0.5, 0) + end + elseif obj.Name == "customavatar" then + obj = _helpers.replaceWithAvatar(obj, "claire") + obj.OnCollisionBegin = function(self, other) + if other ~= Player then + return + end + _helpers.lookAt(self.avatarContainer, other) + dialog:create( + "Hey! Edit your avatar in the Profile Menu, or use the changing room! 👕👖🥾", + self.avatar + ) + Menu:HighlightProfile() + end + obj.OnCollisionEnd = function(self, other) + if other ~= Player then + return + end + _helpers.lookAt(self.avatarContainer, nil) + dialog:remove() + Menu:RemoveHighlight() + end + elseif obj.Name == "friend1" then + hierarchyactions:applyToDescendants(obj, { includeRoot = true }, function(o) + o.Shadow = true + end) + obj = _helpers.replaceWithAvatar(obj, "aduermael") + obj.OnCollisionBegin = function(self, other) + if other ~= Player then + return + end + _helpers.lookAt(self.avatarContainer, other) + end + obj.OnCollisionEnd = function(self, other) + if other ~= Player then + return + end + _helpers.lookAt(self.avatarContainer, nil) + end + elseif obj.Name == "friend2" then + hierarchyactions:applyToDescendants(obj, { includeRoot = true }, function(o) + o.Shadow = true + end) + obj = _helpers.replaceWithAvatar(obj, "gdevillele") + obj.OnCollisionBegin = function(self, other) + if other ~= Player then + return + end + _helpers.lookAt(self.avatarContainer, other) + dialog:create("Looking for friends? Add some through the Friends menu!", self.avatar) + Menu:HighlightFriends() + end + obj.OnCollisionEnd = function(self, other) + if other ~= Player then + return + end + _helpers.lookAt(self.avatarContainer, nil) + dialog:remove() + Menu:RemoveHighlight() + end + elseif obj.Name == "voxels.change_room" then + obj.trigger = _helpers.addTriggerArea(obj, obj.BoundingBox) + obj.trigger.OnCollisionBegin = function(self, other) + if other ~= Player then + return + end + self.toast = toast:create({ + message = "Ready to customize your avatar? 👕", + center = false, + iconShape = bundle.Shape("voxels.change_room"), + duration = -1, -- negative duration means infinite + actionText = "Let's do this!", + action = function() + Menu:ShowProfileWearables() + end, + }) + end + obj.trigger.OnCollisionEnd = function(self, other) + if other ~= Player then + return + end + if self.toast then + self.toast:remove() + self.toast = nil + end + end + elseif obj.Name == "voxels.dj_table" then + local music = bundle.Data("misc/hubmusic.ogg") + if music then + local as = AudioSource() + as.Sound = music + as.Loop = true + as.Volume = 1.0 + as.Radius = 100 + as.Spatialized = true + as:SetParent(obj) + as.LocalPosition = { 0, 0, 0 } + as:Play() + end + elseif obj.Name == "voxels.standing_speaker" then + local speaker = obj:GetChild(1) + local collider = Object() + collider.CollisionBox = speaker.CollisionBox + collider.Physics = PhysicsMode.Static + collider:SetParent(obj) + collider.LocalPosition = speaker.LocalPosition - speaker.Pivot + speaker.Physics = PhysicsMode.Disabled + speaker.originalScale = speaker.Scale:Copy() + table.insert(speakers, obj:GetChild(1)) + elseif obj.Name == "voxels.speaker_left" or obj.Name == "voxels.speaker_right" then + local speaker = obj + local collider = Object() + collider.CollisionBox = Box(speaker.CollisionBox.Min - speaker.Pivot, speaker.CollisionBox.Max - speaker.Pivot) + collider.Physics = PhysicsMode.Static + collider:SetParent(World) + collider.Scale = speaker.Scale + collider.Position = speaker.Position + collider.Rotation = speaker.Rotation + speaker.Physics = PhysicsMode.Disabled + speaker.originalScale = speaker.Scale:Copy() + table.insert(speakers, speaker) + elseif obj.Name == "voxels.portal" then + obj.trigger = _helpers.addTriggerArea(obj, obj.BoundingBox) + obj.trigger.OnCollisionBegin = function(self, other) + if other ~= Player then + return + end + self.toast = toast:create({ + message = "Ready to explore other Worlds? 🌎", + center = false, + iconShape = bundle.Shape("voxels.portal"), + duration = -1, -- negative duration means infinite + actionText = "Let's go!", + action = function() + Menu:ShowWorlds() + end, + }) + end + obj.trigger.OnCollisionEnd = function(self, other) + if other ~= Player then + return + end + if self.toast then + self.toast:remove() + self.toast = nil + end + end + local animatePortal = function(portal) + local kANIMATION_SPEED = 1 + local kOFFSET_Y = 16 + + local ringsParent = portal:GetChild(2) + hierarchyactions:applyToDescendants(ringsParent, { includeRoot = true }, function(o) + o.Physics = PhysicsMode.Trigger + o.IsUnlit = true + end) + + ringsParent.OnCollisionBegin = function(_, other) + if other.CollisionGroups == Player.CollisionGroups then + kANIMATION_SPEED = 5 + end + end + ringsParent.OnCollisionEnd = function(_, other) + if other.CollisionGroups == Player.CollisionGroups then + kANIMATION_SPEED = 1 + end + end + local rings, start, range, speed, timer = {}, {}, {}, {}, {} + + for i = 1, ringsParent.ChildrenCount do + rings[i] = ringsParent:GetChild(i) + rings[i].Scale = rings[i].Scale * (1 - 0.01 * i) --Clipping OTP + start[i] = math.random(-4, 4) + range[i] = math.random(4, 8) + speed[i] = math.random(1, 2) * 0.5 + timer[i] = math.random(1, 5) + rings[i].Tick = function(self, dt) + timer[i] = timer[i] + speed[i] * dt * kANIMATION_SPEED + self.LocalPosition.Y = kOFFSET_Y + start[i] + math.sin(timer[i]) * range[i] + end + end + end + animatePortal(obj) + elseif obj.Name == "pet_npc" then + obj = _helpers.replaceWithAvatar(obj, "voxels") + obj.OnCollisionBegin = function(self, other) + if other ~= Player then + return + end + _helpers.lookAt(self.avatarContainer, other) + if DEBUG_PET then + pet:dialogTree(self.avatar) + else + pet:dialogTreeTeaser(self.avatar) + end + end + obj.OnCollisionEnd = function(self, other) + if other ~= Player then + return + end + _helpers.lookAt(self.avatarContainer, nil) + dialog:remove() + end + elseif string.find(obj.fullname, "discord_sign") then + obj.trigger = _helpers.addTriggerArea(obj) + obj.trigger.OnCollisionBegin = function(self, other) + if other ~= Player then + return + end + local icon + pcall(function() + icon = bundle.Shape("aduermael.discord_logo") + end) + self.toast = toast:create({ + message = "Might wanna join Cubzh's Discord to meet other players & builders?", + center = false, + iconShape = icon, + duration = -1, -- negative duration means infinite + actionText = "Sure!", + action = function() + URL:Open("https://discord.gg/cubzh") + end, + }) + end + obj.trigger.OnCollisionEnd = function(self, other) + if other ~= Player then + return + end + if self.toast then + self.toast:remove() + self.toast = nil + end + end + elseif obj.Name == "pet_bird" or obj.Name == "pet_gator" or obj.Name == "pet_ram" then + obj.initialForward = obj.Forward:Copy() + obj.Physics = PhysicsMode.Disabled + obj.trigger = _helpers.addTriggerArea(obj) + obj.trigger.OnCollisionBegin = function(_, other) + if other ~= Player then + return + end + _helpers.lookAt(obj, other) + dialog:create("❤️❤️❤️", obj) + end + obj.trigger.OnCollisionEnd = function(_, other) + if other ~= Player then + return + end + _helpers.lookAt(obj, nil) + dialog:remove() + end + elseif obj.Name == "pet_spawner" then + obj.Physics = PhysicsMode.Disabled + for i = 1, 4 do -- 1 : player, 2 : gator, 3 : npc, 4 : fence + obj:GetChild(i).Physics = PhysicsMode.Disabled + obj.Palette[1].Color.A = 20 + end + end + + if obj.fullname ~= nil then + if + string.find(obj.fullname, "hedge") + or string.find(obj.fullname, "hay_bail") + or string.find(obj.fullname, "palm_tree") + or string.find(obj.fullname, "apple_tree") + or string.find(obj.fullname, "carrot_1") + or string.find(obj.fullname, "turnip") + or string.find(obj.fullname, "training_dummy") + or string.find(obj.fullname, "farmhat") + or string.find(obj.fullname, "broken_bridge_side_1") + or string.find(obj.fullname, "clothes_rack") + or string.find(obj.fullname, "city_lamp") + or string.find(obj.fullname, "solo_computer") + or string.find(obj.fullname, "no_fun_sign") + or string.find(obj.fullname, "soon") + or string.find(obj.fullname, "brick") + or string.find(obj.fullname, "small_water_pipe") + or string.find(obj.fullname, "pipe_tank") + or string.find(obj.fullname, "beach_umbrella") + or string.find(obj.fullname, "beach_chair") + then + hierarchyactions:applyToDescendants(obj, { includeRoot = true }, function(o) + o.Physics = PhysicsMode.Static + end) + elseif string.find(obj.fullname, "walking_plank") then + hierarchyactions:applyToDescendants(obj, { includeRoot = false }, function(o) + o.Physics = PhysicsMode.Disabled + end) + obj.Physics = PhysicsMode.Static + elseif + string.find(obj.fullname, "fence_gate") + or string.find(obj.fullname, "white_fence") + or string.find(obj.fullname, "rustic_fence") + then + hierarchyactions:applyToDescendants(obj, { includeRoot = true }, function(o) + o.Physics = PhysicsMode.Static + o.CollisionGroups = BARRIER_COLLISION_GROUPS + o.CollidesWithGroups = BARRIER_COLLIDES_WITH_GROUPS + end) + elseif string.find(obj.fullname, "beach_barrier") then + -- fix beach barries alignments (for better collisions) + if obj.fullname == "voxels.beach_barrier_2" then + hierarchyactions:applyToDescendants(obj, { includeRoot = true }, function(o) + o.Physics = PhysicsMode.StaticPerBlock + o.CollisionGroups = BARRIER_COLLISION_GROUPS + o.CollidesWithGroups = BARRIER_COLLIDES_WITH_GROUPS + end) + else + hierarchyactions:applyToDescendants(obj, { includeRoot = true }, function(o) + o.Physics = PhysicsMode.Static + o.CollisionGroups = BARRIER_COLLISION_GROUPS + o.CollidesWithGroups = BARRIER_COLLIDES_WITH_GROUPS + end) + + -- trying to allign collision boxes to smoothly slide + -- along tiled barries. + -- doesn't work realy well so far + + local p = obj.Position:Copy() + p.X = math.floor(p.X + 0.5) + p.Y = math.floor(p.Y + 0.5) + p.Z = math.floor(p.Z + 0.5) + local diff = obj.Position - p + + obj.CollisionBox = Box( + Number3( + math.floor(obj.CollisionBox.Max.X + diff.X + 0.5), + 14, + math.floor(obj.CollisionBox.Max.Z + diff.Z + 0.5) + ), + Number3( + math.floor(obj.CollisionBox.Min.X + diff.X + 0.5), + math.floor(obj.CollisionBox.Min.Y + diff.Y + 0.5), + math.floor(obj.CollisionBox.Min.Z + diff.Z + 0.5) + ) + ) + end + + -- obj.Position.X = math.floor(obj.Position.X + 0.5) + -- obj.Position.Y = math.floor(obj.Position.Y + 0.5) + -- obj.Position.Z = math.floor(obj.Position.Z + 0.5) + elseif string.find(obj.fullname, "plank_") then -- items that are "part of the map" + hierarchyactions:applyToDescendants(obj, { includeRoot = false }, function(o) + o.Physics = PhysicsMode.Disabled + end) + obj.Physics = PhysicsMode.Static + obj.CollisionGroups = MAP_COLLISION_GROUPS + obj.CollidesWithGroups = MAP_COLLIDES_WITH_GROUPS + elseif + string.find(obj.fullname, "shell_1") + or string.find(obj.fullname, "shell_2") + or string.find(obj.fullname, "shell_3") + or string.find(obj.fullname, "sand_1") + or string.find(obj.fullname, "sand_2") + or string.find(obj.fullname, "sand_3") + or string.find(obj.fullname, "sand_4") + or string.find(obj.fullname, "lily_pads") + or string.find(obj.fullname, "vines") + or string.find(obj.fullname, "moss") + then + hierarchyactions:applyToDescendants(obj, { includeRoot = true }, function(o) + o.Physics = PhysicsMode.Disabled + end) + elseif + string.find(obj.fullname, "tuft") + or string.find(obj.fullname, "grass") + or string.find(obj.fullname, "dirt") + then + hierarchyactions:applyToDescendants(obj, { includeRoot = true }, function(o) + o.Physics = PhysicsMode.Disabled + end) + if string.find(obj.Name, "_n") then + return + end + obj.Position.Y = obj.Position.Y - 0.40 * MAP_SCALE + elseif string.find(obj.fullname, "stone") or string.find(obj.fullname, "log") then + hierarchyactions:applyToDescendants(obj, { includeRoot = true }, function(o) + o.Physics = PhysicsMode.Static + end) + if string.find(obj.Name, "_n") then + return + end + obj.Position.Y = obj.Position.Y - 0.40 * MAP_SCALE + end + end +end + +Pointer.Click = function(pe) + Player:SwingRight() + multi:action("swingRight") + dialog:complete() + + if DEBUG_ITEMS then + local impact = pe:CastRay(ITEM_BUILDING_AND_BARRIER_COLLISION_GROUPS) + if impact ~= nil then + if impact.Object ~= nil then + local o = impact.Object + + while o.Parent ~= World do + o = o.Parent + end + + if o ~= nil then + if o.fullname ~= nil then + print(o.fullname, "(copied)") + Dev:CopyToClipboard(o.fullname) + elseif o.Name ~= nil then + print(o.Name, "(copied)") + Dev:CopyToClipboard(o.fullname) + end + end + end + end + end +end + +local JUMP_VELOCITY = 82 +local MAX_AIR_JUMP_VELOCITY = 85 +initPlayer = function(p) + if p == Player then -- Player properties for local simulation + require("camera_modes"):setThirdPerson({ + rigidity = 0.4, + target = p, + collidesWithGroups = CAMERA_COLLIDES_WITH_GROUPS, + }) + + jumpParticles = particles:newEmitter({ + life = function() + return 0.3 + end, + velocity = function() + local v = Number3(15 + math.random() * 10, 0, 0) + v:Rotate(0, math.random() * math.pi * 2, 0) + return v + end, + color = function() + return Color.White + end, + acceleration = function() + return -Config.ConstantAcceleration + end, + collidesWithGroups = function() + return {} + end, + }) + collectParticles = particles:newEmitter({ + life = function() + return 1.0 + end, + velocity = function() + local v = Number3(20 + math.random() * 10, 0, 0) + v:Rotate(0, math.random() * math.pi * 2, 0) + v.Y = 30 + math.random() * 20 + return v + end, + color = function() + return Color.White + end, + scale = function() + return 0.5 + end, + collidesWithGroups = function() + return {} + end, + }) + local spawnJumpParticles = function(o) + jumpParticles.Position = o.Position + jumpParticles:spawn(10) + sfx("walk_concrete_2", { Position = o.Position, Volume = 0.2 }) + end + skills.addStepClimbing(Player, { + mapScale = MAP_SCALE, + collisionGroups = Map.CollisionGroups + ITEM_COLLISION_GROUPS + BUILDING_COLLISION_GROUPS, + }) + skills.addJump(Player, { + maxGroundDistance = 1.0, + airJumps = 1, + jumpVelocity = JUMP_VELOCITY, + maxAirJumpVelocity = MAX_AIR_JUMP_VELOCITY, + onJump = spawnJumpParticles, + onAirJump = spawnJumpParticles, + }) + localPlayerShown = true + + -- Timer to save recent on ground positions + local saveIdx = 1 + Timer(SAVE_INTERVAL, true, function() + if Player.IsOnGround then + savedPositions[saveIdx] = Player.Position:Copy() + { 0, MAP_SCALE, 0 } -- adding a one block Y offset on the respawn + savedRotations[saveIdx] = Player.Rotation:Copy() + saveIdx = saveIdx + 1 + if saveIdx > SAVE_AMOUNT then + saveIdx = 1 + end + end + end) + + p.Head:AddChild(AudioListener) -- Adding an audio listener to the player + end + + World:AddChild(p) -- Adding the player to the world + p.Physics = PhysicsMode.Dynamic + p.CollisionGroups = PLAYER_COLLISION_GROUPS + p.CollidesWithGroups = PLAYER_COLLIDES_WITH_GROUPS + addPlayerAnimations(p) -- Adding animations + walkSFX:register(p) -- Adding step sounds + playerControls:walk(p) -- Setting the default control to walk +end + +function dropPlayer(p) + playerControls:walk(p) + p.Velocity, p.Motion = { 0, 0, 0 }, { 0, 0, 0 } + + if p == Player then + -- cycling through saved positions to find a valid one + for k, v in ipairs(savedPositions) do + local ray = Ray(v, Number3.Down) + if ray:Cast(Map) ~= nil then + p.Position = v + p.Rotation = savedRotations[k] + return + end + end + end + + p.Position = SPAWN_POSITION + Number3(math.random(-6, 6), 0, math.random(-6, 6)) + p.Rotation = SPAWN_ROTATION + Number3(0, math.random(-1, 1) * math.pi * 0.08, 0) +end + +local HOLDING_TIME = 0.6 -- time to trigger action when holding button pressed +local holdTimer = nil +function action1() + playerControls:walk(Player) + skills.jump(Player) + + holdTimer = Timer(HOLDING_TIME, function() + holdTimer = nil + if backEquipment == "" then + return + end + if backEquipment == "glider" then + if gliderUsageToast ~= nil then + gliderUsageToast:remove() + gliderUsageToast = nil + end + playerControls:glide(Player) + end + end) + + if DEBUG_AMBIENCES then + nextAmbience() + end +end + +function action1Release() + if holdTimer ~= nil then + holdTimer:Cancel() + end +end + +function mapEffects() + local sea = Map:GetChild(1) + sea.Physics = PhysicsMode.TriggerPerBlock -- let the player go through + sea.CollisionGroups = {} + sea.CollidesWithGroups = { 2 } + sea.InnerTransparentFaces = false -- no inner surfaces for the renderer + sea.LocalPosition = { 0, 1, 0 } -- placement + local t = 0 + sea.Tick = function(self, dt) + t = t + dt + self.Scale.Y = 1 + (math.sin(t) * 0.05) + end + sea.OnCollisionBegin = function(_, other) + sfx("water_impact_" .. math.random(1, 3), { Position = other.Position, Volume = 0.5, Pitch = 1.0 }) + end + + local grass = Map:GetChild(2) + grass.Physics = PhysicsMode.Disabled + grass.CollisionGroups = { 1 } + grass.Scale = 0.999 + grass.LocalPosition = { 5, 12.1, 27 } +end + +addTimers = function() + Timer(TIME_TO_AVATAR_CTA, function() + require("api").getAvatar(Player.Username, function(err, data) + if not err then + if + data.hair ~= nil + or data.jacket ~= "official.jacket" + or data.pants ~= "official.pants" + or data.boots ~= "official.boots" + then + return -- If the player has at least one customized equipment, don't send toastMsg + else + toast:create({ + message = "You can customize your avatar anytime!", + center = false, + iconShape = bundle.Shape("voxels.change_room"), + duration = -1, + closeButton = true, + actionText = "Ok!", + action = function(self) + Menu:ShowProfileWearables() + self:remove() + end, + }) + end + end + end) + end) + + Timer(TIME_TO_FRIENDS_CTA, function() + require("api"):getFriendCount(function(ok, count) + if ok then + if count > 0 then + return -- If the player already has friends, don't toastMsg + else + toast:create({ + message = "Add friends and play together!", + center = false, + iconShape = bundle.Shape("voxels.friend_icon"), + duration = -1, + closeButton = true, + actionText = "Ok!", + action = function(self) + Menu:ShowFriends() + self:remove() + end, + }) + end + end + end) + end) +end + +local _ambiences +local _nextAmbience +function nextAmbience() + if _ambiences == nil then + _ambiences = { dawn, day, dusk, night } + _nextAmbience = 1 + end + local a = _ambiences[_nextAmbience] + _ambiences:set(a) + + _nextAmbience = _nextAmbience + 1 + if _nextAmbience > #_ambiences then + _nextAmbience = 1 + end +end +-- HELPERS + +_helpers = {} +_helpers.stringStartsWith = function(str, prefix) + return string.sub(str, 1, string.len(prefix)) == prefix +end + +_helpers.stringRemovePrefix = function(str, prefix) + if string.sub(str, 1, string.len(prefix)) == prefix then + return string.sub(str, string.len(prefix) + 1) + else + return str + end +end + +_helpers.replaceWithAvatar = function(obj, name) + local o = Object() + o:SetParent(World) + o.Position = obj.Position + o.Scale = obj.Scale + o.Physics = PhysicsMode.Trigger + + o.CollisionBox = Box({ + -TRIGGER_AREA_SIZE.Width * 0.5, + math.min(-TRIGGER_AREA_SIZE.Height, o.CollisionBox.Min.Y), + -TRIGGER_AREA_SIZE.Depth * 0.5, + }, { + TRIGGER_AREA_SIZE.Width * 0.5, + math.max(TRIGGER_AREA_SIZE.Height, o.CollisionBox.Max.Y), + TRIGGER_AREA_SIZE.Depth * 0.5, + }) + o.CollidesWithGroups = { 2 } + o.CollisionGroups = {} + + local container = Object() + container.Rotation = obj.Rotation + container.initialRotation = obj.Rotation:Copy() + container.initialForward = obj.Forward:Copy() + container:SetParent(o) + o.avatarContainer = container + + local newObj = avatar:get(name) + o.avatar = newObj + newObj:SetParent(o.avatarContainer) + + obj:RemoveFromParent() + return o +end + +_helpers.addTriggerArea = function(obj, box, offset) + local o = Object() + o:SetParent(World) + o.Scale = obj.Scale + o.Physics = PhysicsMode.Trigger + o.CollidesWithGroups = { 2 } + o.CollisionGroups = {} + o.CollisionBox = box ~= nil and box + or ( + Box({ + -TRIGGER_AREA_SIZE.Width * 0.5, + math.min(-TRIGGER_AREA_SIZE.Height, o.CollisionBox.Min.Y), + -TRIGGER_AREA_SIZE.Depth * 0.5, + }, { + TRIGGER_AREA_SIZE.Width * 0.5, + math.max(TRIGGER_AREA_SIZE.Height, o.CollisionBox.Max.Y), + TRIGGER_AREA_SIZE.Depth * 0.5, + }) + ) + o.Position = offset ~= nil and (obj.Position + offset) or (obj.Position - obj.Pivot * 0.5) + return o +end + +_helpers.lookAt = function(obj, target) + if not target then + ease:linear(obj, 0.1).Forward = obj.initialForward + obj.Tick = nil + return + end + obj.Tick = function(self, _) + _helpers.lookAtHorizontal(self, target) + end +end + +_helpers.lookAtHorizontal = function(o1, o2) + local n3_1 = Number3.Zero + local n3_2 = Number3.Zero + n3_1:Set(o1.Position.X, 0, o1.Position.Z) + n3_2:Set(o2.Position.X, 0, o2.Position.Z) + ease:linear(o1, 0.1).Forward = n3_2 - n3_1 +end + +_helpers.contains = function(t, v) + for _, value in ipairs(t) do + if value == v then + return true + end + end + return false +end + +-- MODULE : DAY NIGHT CYCLE + +dawn = { + sky = { + skyColor = Color(246, 40, 140), + horizonColor = Color(239, 147, 17), + abyssColor = Color(0, 77, 172), + lightColor = Color(177, 111, 55), + lightIntensity = 0.510000, + }, + fog = { + color = Color(74, 15, 6), + near = 300, + far = 700, + lightAbsorbtion = 0.400000, + }, + sun = { + color = Color(172, 71, 71), + intensity = 1.000000, + rotation = Rotation(math.rad(30), math.rad(-60), 0), + }, + ambient = { + skyLightFactor = 0.100000, + dirLightFactor = 0.200000, + }, +} + +day = { + sky = { + skyColor = Color(0, 103, 255), + horizonColor = Color(0, 248, 248), + abyssColor = Color(202, 255, 245), + lightColor = Color(199, 174, 148), + lightIntensity = 0.600000, + }, + fog = { + color = Color(20, 159, 204), + near = 300, + far = 700, + lightAbsorbtion = 0.400000, + }, + sun = { + color = Color(199, 195, 73), + intensity = 1.000000, + rotation = Rotation(math.rad(50), math.rad(-30), 0), + }, + ambient = { + skyLightFactor = 0.100000, + dirLightFactor = 0.200000, + }, +} + +dusk = { + sky = { + skyColor = Color(0, 9, 192), + horizonColor = Color(227, 43, 70), + abyssColor = Color(238, 168, 0), + lightColor = Color(180, 51, 180), + lightIntensity = 0.510000, + }, + fog = { + color = Color(10, 15, 83), + near = 300, + far = 700, + lightAbsorbtion = 0.400000, + }, + sun = { + color = Color(91, 28, 164), + intensity = 1.000000, + rotation = Rotation(math.rad(30), math.rad(60), 0), + }, + ambient = { + skyLightFactor = 0.100000, + dirLightFactor = 0.210000, + }, +} + +night = { + sky = { + skyColor = Color(1, 3, 50), + horizonColor = Color(70, 64, 146), + abyssColor = Color(64, 117, 190), + lightColor = Color(69, 80, 181), + lightIntensity = 0.600000, + }, + fog = { + color = Color(89, 28, 112), + near = 310, + far = 700, + lightAbsorbtion = 0.310000, + }, + sun = { + color = Color(107, 65, 200), + intensity = 1.000000, + rotation = Rotation(1.061161, 3.089219, 0.000000), + }, + ambient = { + skyLightFactor = 0.100000, + dirLightFactor = 0.200000, + }, +} + +-- MODULE : PLAYER CONTROLS + +function updateSync() + local p = Player + local pID = p.ID + + multi:unlink("g_" .. pID) + multi:unlink("p_" .. pID) + multi:unlink("ph_" .. pID) + + if Client.Connected then + local playerControlID = playerControls:getPlayerID(p) + local vehicle = playerControls.vehicles[playerControlID] + if vehicle then + if vehicle.type == "glider" then + -- sync vehicleRoll child object, + -- it contains all needed information + multi:sync(vehicle.roll, "g_" .. pID, { + keys = { "Velocity", "Position", "Rotation" }, + triggers = { "LocalRotation", "Velocity" }, + }) + end + else + multi:sync(p, "p_" .. pID, { + keys = { "Motion", "Velocity", "Position", "Rotation.Y" }, + triggers = { "LocalRotation", "Rotation", "Motion", "Position", "Velocity" }, + }) + multi:sync( + p.Head, + "ph_" .. pID, + { keys = { "LocalRotation.X" }, triggers = { "LocalRotation", "Rotation" } } + ) + end + end +end + +function addPlayerAnimations(player) + local animLiftArms = Animation("LiftArms", { speed = 5, loops = 1, removeWhenDone = false, priority = 255 }) + local liftRightArm = { + { time = 0.0, rotation = { 0, 0, -1.0472 } }, + { time = 1.0, rotation = { 0, 0, math.rad(30) } }, + } + local liftRightHand = { + { time = 0.0, rotation = { 0, -0.392699, 0 } }, + { time = 1.0, rotation = { math.rad(-180), 0, math.rad(-30) } }, + } + local liftLeftArm = { + { time = 0.0, rotation = { 0, 0, 1.0472 } }, + { time = 1.0, rotation = { 0, 0, math.rad(-30) } }, + } + local liftLeftHand = { + { time = 0.0, rotation = { 0, -0.392699, 0 } }, + { time = 1.0, rotation = { math.rad(-180), 0, math.rad(30) } }, + } + local animLiftRightConfig = { + RightArm = liftRightArm, + RightHand = liftRightHand, + LeftArm = liftLeftArm, + LeftHand = liftLeftHand, + } + for name, v in pairs(animLiftRightConfig) do + for _, frame in ipairs(v) do + animLiftArms:AddFrameInGroup(name, frame.time, { position = frame.position, rotation = frame.rotation }) + animLiftArms:Bind( + name, + (name == "Body" and not player.Avatar[name]) and player.Avatar or player.Avatar[name] + ) + end + end + player.Animations.LiftArms = animLiftArms +end + +playerControls = { + shapeCache = {}, + vehicles = {}, -- vehicles, indexed by player ID + current = {}, -- control names, indexed by player ID + onDrag = nil, + dirPad = nil, +} + +playerControls.getPlayerID = function(_, player) + if player == Player then + -- using "local" because the local player ID may change while still maintaining active controls + return "local" + else + return player.ID + end +end + +playerControls.pointerDrag = function(pe) + if playerControls.onDrag ~= nil then + playerControls.onDrag(pe) + end +end + +playerControls.directionalPad = function(x, y) + if playerControls.dirPad ~= nil then + playerControls.dirPad(x, y) + end +end + +playerControls.getShape = function(self, shapeName) + if self.shapeCache[shapeName] == nil then + self.shapeCache[shapeName] = bundle.Shape(shapeName) + end + return Shape(self.shapeCache[shapeName], { includeChildren = true }) +end + +playerControls.exitVehicle = function(self, player) + local pID = self:getPlayerID(player) + + local vehicle = self.vehicles[pID] + + if vehicle == nil then + return + end + + if player.Animations.LiftArms then + player.Animations.LiftArms:Stop() + end + + vehicle.Tick = nil + + -- NOTE: each vehicle should implement an onExit + -- callback instead of harcoding everything here. + + if vehicle.wingTrails then + for _, t in ipairs(vehicle.wingTrails) do + wingTrail:remove(t) + end + vehicle.wingTrails = nil + end + + player:SetParent(World, true) + player.Rotation = { 0, vehicle.Rotation.Y, 0 } + player.Position = vehicle.Position + { 0, -27, 0 } + + player.Head.LocalRotation = { 0, 0, 0 } + player.Physics = PhysicsMode.Dynamic + player.Scale = 0.5 + player.Velocity = Number3.Zero + + if player == Player then + require("camera_modes"):setThirdPerson({ + rigidity = 0.4, + target = player, + collidesWithGroups = CAMERA_COLLIDES_WITH_GROUPS, + }) + Camera.FOV = cameraDefaultFOV + end + + vehicle.Physics = PhysicsMode.Disabled + + if vehicle.model ~= nil then + vehicle.model.Physics = PhysicsMode.Disabled + vehicle.model:SetParent(player, true) + vehicle:RemoveFromParent() + ease:linear(vehicle.model, 0.3, { + onDone = function(o) + o:RemoveFromParent() + end, + }).Scale = + Number3.Zero + else + vehicle:SetParent(World, true) + ease:linear(vehicle, 0.3, { + onDone = function(o) + o:RemoveFromParent() + end, + }).Scale = Number3.Zero + end + + self.vehicles[pID] = nil +end + +playerControls.walk = function(self, player) + local pID = self:getPlayerID(player) + + if self.current[pID] == "walk" then + return -- already walking + end + self.current[pID] = "walk" + + self:exitVehicle(player) + + player:SetParent(World, true) + + if player == Player then + self.onDrag = function(pe) + Player.LocalRotation = Rotation(0, pe.DX * 0.01, 0) * Player.LocalRotation + Player.Head.LocalRotation = Rotation(-pe.DY * 0.01, 0, 0) * Player.Head.LocalRotation + local dpad = require("controls").DirectionalPadValues + Player.Motion = (Player.Forward * dpad.Y + Player.Right * dpad.X) * 50 + end + self.dirPad = function(x, y) + Player.Motion = (Player.Forward * y + Player.Right * x) * 50 + end + updateSync() + end +end + +local GLIDER_MAX_SPEED_FOR_EFFECTS = 80 -- speed can be above that, but used for visual effects +local GLIDER_MAX_SPEED = 200 +local GLIDER_WING_LENGTH = 24 +local GLIDER_MAX_START_SPEED = 50 +local GLIDER_DRAG_DOWN = -400 + +playerControls.glide = function(self, player) + local pID = self:getPlayerID(player) + + if self.current[pID] == "glide" then + return -- already gliding + end + self.current[pID] = "glide" + + self:exitVehicle(player) + + local vehicle = Object() + vehicle.Scale = 0.5 + vehicle:SetParent(World) + vehicle.type = "glider" + + self.vehicles[pID] = vehicle + + local glider = self:getShape("voxels.glider") + glider.Shadow = true + glider.Physics = PhysicsMode.Disabled + vehicle.model = glider + + vehicle.Position = player:PositionLocalToWorld({ 0, player.BoundingBox.Max.Y - 2, 0 }) + + glider.Scale = 0 + ease:cancel(glider) + ease:outElastic(glider, 0.3).Scale = Number3(1, 1, 1) + + if player.Animations.LiftArms then + player.Animations.LiftArms:Play() + end + + vehicle.Physics = PhysicsMode.Dynamic + -- vehicle.Tick resets velocity every frame, it means we have to emulate each individual part ourself + -- instead of letting velocity compound (from forces, other objects, collision responses etc.), it also + -- means that vehicle.Acceleration does nothing for us + vehicle.gliderSpd = 0 + vehicle.gliderPull = Number3(0, GLIDER_DRAG_DOWN, 0) + + local rightTrail = wingTrail:create({ scale = 0.5 }) + rightTrail.LocalPosition = { GLIDER_WING_LENGTH, 8, 0 } + + local leftTrail = wingTrail:create({ scale = 0.5 }) + leftTrail.LocalPosition = { -GLIDER_WING_LENGTH, 8, 0 } + + vehicle.wingTrails = {} + table.insert(vehicle.wingTrails, rightTrail) + table.insert(vehicle.wingTrails, leftTrail) + + local rightWingTip + local leftWingTip + local diffY + local maxDiff + local p + local leftLift + local rightLift + local down + local up + local speedOverMax + local f + local dot + + vehicle.Rotation:Set(0, player.Rotation.Y, 0) + local initSpd = (player.Motion + player.Velocity * 0.1).Length + vehicle.gliderSpd = math.min(initSpd, GLIDER_MAX_START_SPEED) + + player.Head.LocalRotation = { 0, 0, 0 } + + player.Motion:Set(0, 0, 0) + player.Velocity:Set(0, 0, 0) + player.Physics = PhysicsMode.Disabled + player.Scale = 1.0 + + if player == Player then -- local simulation + local yaw = Rotation(0, 0, 0) + local yawDelta = Rotation(0, 0, 0) + + local tilt = Rotation(0, 0, 0) + local tiltDelta = Rotation(0, 0, 0) + + local roll = Rotation(0, 0, 0) + local rollDelta = Rotation(0, 0, 1) + + local vehicleRoll = Object() + vehicleRoll.Velocity = Number3.Zero + vehicleRoll.Physics = PhysicsMode.Disabled + vehicleRoll:SetParent(vehicle) + glider:SetParent(vehicleRoll) + glider.LocalRotation = { 0, math.rad(180), 0 } + + vehicle.roll = vehicleRoll -- used for sync + + yaw:Set(0, player.Rotation.Y, 0) + + player:SetParent(vehicleRoll, true) + player.LocalRotation:Set(Number3.Zero) + player.LocalPosition:Set(0, -27, 0) + + rightTrail:SetParent(vehicleRoll) + leftTrail:SetParent(vehicleRoll) + + vehicleRoll.Velocity:Set(vehicle.Velocity) -- copying for sync (physics disabled on vehicleRoll) + + vehicle.CollisionBox = Box({ -10, -30, -10 }, { 10, 14, 10 }) + vehicle.CollidesWithGroups = MAP_COLLISION_GROUPS + + BUILDING_COLLISION_GROUPS + + ITEM_COLLISION_GROUPS + + DRAFT_COLLISION_GROUPS + + BARRIER_COLLISION_GROUPS + vehicle.CollisionGroups = {} + + vehicle.OnCollisionBegin = function(_, other) + if other.CollisionGroups == DRAFT_COLLISION_GROUPS then + return + end + playerControls:walk(player) + end + + vehicle.Tick = function(o, dt) + rightWingTip = vehicleRoll:PositionLocalToWorld(GLIDER_WING_LENGTH, 0, 0) + leftWingTip = vehicleRoll:PositionLocalToWorld(-GLIDER_WING_LENGTH, 0, 0) + + diffY = leftWingTip.Y - rightWingTip.Y + + maxDiff = GLIDER_WING_LENGTH * 2 + p = math.abs(diffY / maxDiff) + + if diffY < 0 then + leftLift = 0.5 + p * 0.5 + rightLift = 1.0 - leftLift + else + rightLift = 0.5 + p * 0.5 + leftLift = 1.0 - rightLift + end + + yawDelta.Y = diffY * dt * 0.001 * 70 + yaw = yawDelta * yaw + + o.Rotation = yaw * tilt + + dot = o.Forward:Dot(Number3.Down) + down = math.max(0, dot) -- 0 -> 1 + up = math.max(0, -dot) + + -- accelerate when facing down / lose more velocity when going up + o.gliderSpd = o.gliderSpd + down * 80.0 * dt - (8.0 + up * 30.0) * dt + o.gliderSpd = math.max(o.gliderSpd, 0) + o.gliderSpd = math.min(GLIDER_MAX_SPEED, o.gliderSpd) + + o.Velocity:Set(o.Forward * o.gliderSpd + o.gliderPull * dt) + vehicleRoll.Velocity:Set(o.Velocity) -- copying for sync (physics disabled on vehicleRoll) + + -- EFFECTS + speedOverMax = math.min(1.0, o.gliderSpd / GLIDER_MAX_SPEED_FOR_EFFECTS) + Camera.FOV = cameraDefaultFOV + 20 * speedOverMax + + f = 0.2 * speedOverMax + rightTrail:setColor(Color(255, 255, 255, rightLift * f)) + leftTrail:setColor(Color(255, 255, 255, leftLift * f)) + end + + require("camera_modes"):setThirdPerson({ + rigidity = 0.3, + target = vehicle, + rotationOffset = Rotation(math.rad(20), 0, 0), + collidesWithGroups = CAMERA_COLLIDES_WITH_GROUPS, + }) + + self.onDrag = function(pe) + rollDelta.Z = -pe.DX * 0.01 + tiltDelta.X = -pe.DY * 0.01 + + roll = rollDelta * roll + tilt = tiltDelta * tilt + + vehicle.Rotation = yaw * tilt + vehicleRoll.LocalRotation = roll -- triggers sync + end + + self.dirPad = function(_, _) + -- nothing to do, just turning off walk controls + end + + updateSync() + else -- distant player + glider:SetParent(vehicle) + glider.LocalRotation = { 0, math.rad(180), 0 } + + player:SetParent(vehicle, true) + player.LocalRotation:Set(Number3.Zero) + player.LocalPosition:Set(0, -27, 0) + + rightTrail:SetParent(vehicle) + leftTrail:SetParent(vehicle) + + vehicle.Tick = function(o, _) + -- only update wing trail colors + -- no local simulation (for now?), looks good enough so far + + rightWingTip = vehicle:PositionLocalToWorld(GLIDER_WING_LENGTH, 0, 0) + leftWingTip = vehicle:PositionLocalToWorld(-GLIDER_WING_LENGTH, 0, 0) + + diffY = leftWingTip.Y - rightWingTip.Y + + maxDiff = GLIDER_WING_LENGTH * 2 + p = math.abs(diffY / maxDiff) + + if diffY < 0 then + leftLift = 0.5 + p * 0.5 + rightLift = 1.0 - leftLift + else + rightLift = 0.5 + p * 0.5 + leftLift = 1.0 - rightLift + end + + l = o.Velocity.Length + + -- EFFECTS + speedOverMax = math.min(1.0, l / GLIDER_MAX_SPEED_FOR_EFFECTS) + + f = 0.2 * speedOverMax + rightTrail:setColor(Color(255, 255, 255, rightLift * f)) + leftTrail:setColor(Color(255, 255, 255, leftLift * f)) + end + end + + return vehicle +end + +-- MODULE : COLLECTIBLES + +collectedGliderParts = {} +gliderBackpackCollectibles = {} +gliderUnlocked = false + +local REQUEST_FAIL_RETRY_DELAY = 5.0 +-- local GLIDER_PARTS = 10 + +backEquipment = nil + +-- function resetKVS() +-- -- if debug then +-- local retry = {} +-- retry.fn = function() +-- local store = KeyValueStore(Player.UserID) +-- store:set("collectedGliderParts", {}, "collectedJetpackParts", {}, "CollectedNerfParts", {}, function(ok) +-- if not ok then +-- Timer(REQUEST_FAIL_RETRY_DELAY, retry.fn) +-- end +-- end) +-- end +-- retry.fn() +-- addCollectibles() +-- -- end +-- end + +function addCollectibles() + conf = require("config") + + gliderParts = {} + + local function unlockGlider() + gliderUnlocked = true + for _, backpack in ipairs(gliderBackpackCollectibles) do + backpack.object.PrivateDrawMode = 0 + end + end + + local function spawnBackpacks() + local defaultBackpackConfig = { + scale = GLIDER_BACKPACK.SCALE, + rotation = Number3.Zero, + position = Number3.Zero, + itemName = GLIDER_BACKPACK.ITEM_NAME, + onCollisionBegin = function(c) + if gliderUnlocked then + collectParticles.Position = c.object.Position + collectParticles:spawn(20) + sfx("wood_impact_3", { Position = c.object.Position, Volume = 0.6, Pitch = 1.3 }) + Client:HapticFeedback() + collectible:remove(c) + + Player:EquipBackpack(c.object) + + backEquipment = "glider" + multi:action("equipGlider") + + gliderUsageToast = toast:create({ + message = "Maintain jump key to start gliding!", + center = false, + iconShape = bundle.Shape("voxels.glider"), + duration = -1, -- negative duration means infinite + }) + else + backpackTransparentToast = toast:create({ + message = #collectedGliderParts .. "/" .. #gliderParts .. " collected", + center = true, + duration = -1, -- negative duration means infinite + iconShape = bundle.Shape("voxels.glider_parts"), + }) + end + end, + onCollisionEnd = function(_) + if backpackTransparentToast then + backpackTransparentToast:remove() + backpackTransparentToast = nil + end + end, + } + + -- To replace by segment below when world editor is fixed + local gliderBackpackConfigs = { + { position = Number3(75, 186, 262) }, + { position = Number3(330, 80, 160) }, + } + + for _, bpConfig in pairs(gliderBackpackConfigs) do + local config = conf:merge(defaultBackpackConfig, bpConfig) + local c = collectible:create(config) + c.object.PrivateDrawMode = 1 + table.insert(gliderBackpackCollectibles, c) + end + + -- segment + --[[ + local bp = World:FindObjectsByName("voxels.glider_backpack") + + for _, v in pairs(bp) do + local config = {position = v.Position} + config = conf:merge(defaultBackpackConfig, config) + local c = collectible:create(config) + c.object.PrivateDrawMode = 1 + table.insert(gliderBackpackCollectibles, c) + end + ]] + end + + local function spawnCollectibles() + -- To replace with segment below when World Editor is fixed + tempPos = { + Number3(264, 80, 504), + Number3(144, 164, 408), + Number3(75, 186, 300), + } + + for k, v in ipairs(tempPos) do + local s = bundle.Shape("voxels.glider_parts") + s.Name = "voxels.glider_parts_" .. k + s.Position = v + table.insert(gliderParts, s) + end + + -- segment + --[[ + for i = 1, GLIDER_PARTS do + table.insert(gliderParts, World:FindObjectByName("voxels.glider_parts_" .. i)) + end + ]] + + local gliderPartConfig = { + scale = 0.5, + itemName = "voxels.glider_parts", + position = Number3.Zero, + userdata = { + ID = -1, + }, + onCollisionBegin = function(c) + collectParticles.Position = c.object.Position + collectParticles:spawn(20) + sfx("wood_impact_3", { Position = c.object.Position, Volume = 0.6, Pitch = 1.3 }) + Client:HapticFeedback() + collectible:remove(c) + if _helpers.contains(collectedGliderParts, c.userdata.ID) then + return + end + + table.insert(collectedGliderParts, c.userdata.ID) + + local retry = {} + retry.fn = function() + local store = KeyValueStore(Player.UserID) + store:set("collectedGliderParts", collectedGliderParts, function(ok) + if not ok then + Timer(REQUEST_FAIL_RETRY_DELAY, retry.fn) + end + end) + end + retry.fn() + + if #collectedGliderParts >= #gliderParts then + -- the last glider part has been collected + toast:create({ + message = "Glider unlocked!", + center = false, + iconShape = bundle.Shape(GLIDER_BACKPACK.ITEM_NAME), + duration = 2, + }) + unlockGlider() + else + -- a glider part has been collected + toast:create({ + message = #collectedGliderParts .. "/" .. #gliderParts .. " collected", + iconShape = bundle.Shape("voxels.glider_parts"), + keepInStack = false, + }) + end + end, + } + + if #collectedGliderParts >= #gliderParts then + unlockGlider() + for _, v in pairs(gliderParts) do + v:RemoveFromParent() + end + else + for k, v in ipairs(gliderParts) do + if not _helpers.contains(collectedGliderParts, k) then + local config = conf:merge(gliderPartConfig, { position = v.Position, userdata = { ID = k } }) + collectible:create(config) + end + v:RemoveFromParent() + end + end + end + + local t = {} + t.get = function() + local store = KeyValueStore(Player.UserID) + store:get("collectedGliderParts", "collectedJetpackParts", function(ok, results) + if type(ok) ~= "boolean" then + error("KeyValueStore:get() unexpected type of 'ok'", 2) + end + if type(results) ~= "table" and type(results) ~= "nil" then + error("KeyValueStore:get() unexpected type of 'results'", 2) + end + if ok == true then + if results.collectedGliderParts ~= nil then + collectedGliderParts = results.collectedGliderParts + end + spawnBackpacks() + spawnCollectibles() + else + Timer(REQUEST_FAIL_RETRY_DELAY, t.get) + end + end) + end + t.get() +end + +-- CREATURES + +pet = {} +pet.dialogTreeTeaser = function(_, target) + dialog:create( + "Hey there! 🙂 You seem like a kind-hearted soul. I'm sure you would take good care of a companion! ✨", + target, + { "➡️ Yes of course!", "➡️ No thank you" }, + function(idx) + if idx == 1 then + dialog:create( + "This machine here can spawn a random egg for you, containing a loving companion!", + target, + { "➡️ Ok!" }, + function() + dialog:create( + "I'm currently fixing it, come back in a few days!", + target, + { "➡️ I'll be back!" }, + function() + dialog:remove() + end + ) + end + ) + elseif idx == 2 then + dialog:create( + "Oh, I could swear you would like a small friend around. Come back if you change your mind!", + target, + { "➡️ Ok!" }, + function() + dialog:remove() + end + ) + end + end + ) +end \ No newline at end of file diff --git a/cubzh/cubzh.lua b/cubzh/scripts/imagine.lua similarity index 50% rename from cubzh/cubzh.lua rename to cubzh/scripts/imagine.lua index e96e4e4..9f9b286 100644 --- a/cubzh/cubzh.lua +++ b/cubzh/scripts/imagine.lua @@ -1,16 +1,6 @@ Config = { Map = "boumety.building_challenge", - Items = { - "kooow.white_yeti", - "kooow.ribberhead_dino", - "petroglyph.hp_bar", - "petroglyph.block_red", - "petroglyph.block_green", - } -} -local opponentsList = { - "kooow.white_yeti", - "kooow.ribberhead_dino", + Items = {} } -- CONSTANTS @@ -23,8 +13,6 @@ faceNormals = { [Face.Left] = Number3(-1,0,0), [Face.Right] = Number3(1,0,0), [Face.Top] = Number3(0,1,0) } -hpBars = {} - Client.OnStart = function() multi = require "multi" @@ -42,24 +30,12 @@ Client.OnStart = function() ui = require "uikit" controls = require "controls" controls:setButtonIcon("action1", "⬆️") - + showInstructions() showWelcomeHint() - -- HP bars array init - for i=1,16,1 do - hpBars[i] = {} - end - Camera:SetModeThirdPerson() dropPlayer(Player) - opponent = spawnRandomOpponent() - attachHPBar(opponent) - - - Player.healthIndex = 0 - Player.name = "player" - attachHPBar(Player) end Pointer.Click = function(pe) @@ -85,7 +61,6 @@ Client.Tick = function(dt) dropPlayer(Player) Player:TextBubble("💀 Oops!") end - updateHPBars(dt) end function cancelMenu() @@ -171,13 +146,12 @@ Client.DidReceiveEvent = function(e) end local s = Shape(e.vox) s.CollisionGroups = Map.CollisionGroups - s.tag = "generatedImage" -- Tagging the object as a generated image s.userInput = e.userInput s.user = e.user s:SetParent(World) -- first block is not at 0,0,0 - -- use collision box min to offset the pivot + -- use collision box min to offset the pivot local collisionBoxMin = s.CollisionBox.Min local center = s.CollisionBox.Center:Copy() @@ -202,27 +176,6 @@ Client.DidReceiveEvent = function(e) end elseif e.action == "otherGen" then makeBubble(e) - elseif e.action == "update_hp" then - local target = Player - if e.targetName ~= "player" then - print("targeting opponent") - target = opponent - end - if target and hpBars[target.healthIndex + 1] then - local hpBarInfo = hpBars[target.healthIndex + 1] - -- Assuming damageAmount is the amount of HP to remove - local damageAmount = tonumber(e.damageAmount) - local currentPercentage = hpBarInfo.hpShape.LocalScale.Z / hpBarMaxLength - local currentHP = currentPercentage * playerMaxHP - -- Calculate new HP after taking damage - local newHP = math.max(currentHP - damageAmount, 0) - local newPercentage = newHP / playerMaxHP - -- Update the target's HP visually - setHPBar(target, newHP, true) -- true to animate the HP bar change - -- if HP reaches 0, explode the target - require("explode"):shapes(target) - target.IsHidden = true - end end end @@ -240,6 +193,7 @@ Server.OnPlayerJoin = function(p) HTTP:Get(d.url, headers, function(data) local e = Event() e.vox = data.Body + print("d.url : " .. d.url) e.id = d.e.id e.pos = d.e.pos e.rotY = d.e.rotY @@ -276,48 +230,7 @@ Server.DidReceiveEvent = function(e) e2:SendTo(Players) end) end) - elseif e.action == "resolve_encounter" then - -- Construct the URL with query parameters for the GET request - local apiURL = "https://gig.ax:5678/encounter/" -- Update this with the correct path if needed - -- Assuming 'opponent' and 'image' are encoded correctly for use in a URL - -- You may need to URL encode these values if they contain spaces or special characters - apiURL = apiURL .. "?opponent=" .. e.opponentName .. "&tool=" .. e.image - - local headers = {} -- Headers if needed, though typically not required for a GET request - - HTTP:Get(apiURL, headers, function(data) - local result = JSON:Decode(data.Body) - if result and result.operations then - print("Encounter resolved successfully: " .. result.description.text) - for _, operation in ipairs(result.operations) do - if operation.name == "HURT" then - -- Assuming 'parameters' contains the target ID and the damage amount - local targetName = operation.parameters[1] - local damageAmount = tonumber(operation.parameters[2]) - - -- Send an event to the client to update the target's HP - local hpUpdateEvent = Event() - hpUpdateEvent.action = "update_hp" - hpUpdateEvent.targetName = targetName - hpUpdateEvent.damageAmount = damageAmount - hpUpdateEvent:SendTo(Players) -- Adjust as necessary to target specific player(s) - elseif operation.name == "NOTHING" then - -- Handle no effect - local updateEvent = Event() - updateEvent.action = "no_effect" - updateEvent.description = result.description.text - print("NOTHING: " .. result.description.text) - updateEvent:SendTo(Players) - end - end - else - -- Handle error or no response - print("Error: can't resolve encounter or no operations returned.") - print(data.Body) - print(result) - end - end) - end + end end -- Utility functions @@ -329,8 +242,6 @@ function hideMenu() deleteTarget() end -local equippedImage = nil -- This will hold the reference to the equipped image object - function showMenu(pointerEvent) hideMenu() @@ -341,9 +252,9 @@ function showMenu(pointerEvent) if impact.Object == Map then local pos = pointerEvent.Position + pointerEvent.Direction * impact.Distance - + showTarget(impact, pos) - + createButton = ui:createButton("➕ Create Image") local px = screenPos.X - createButton.Width * 0.5 @@ -352,7 +263,7 @@ function showMenu(pointerEvent) local py = screenPos.Y + POINTER_OFFSET if py < Screen.SafeArea.Bottom + PADDING then py = Screen.SafeArea.Bottom + PADDING end - if py > Screen.Height - Screen.SafeArea.Top - createButton.Height - PADDING then py = Screen.Height - Screen.SafeArea.Top - createButton.Height - PADDING end + if py > Screen.Height - Screen.SafeArea.Top - createButton.Height - PADDING then py = Screen.Height - Screen.SafeArea.Top - createButton.Height - PADDING end createButton.pos.X = px createButton.pos.Y = py @@ -399,95 +310,40 @@ function showMenu(pointerEvent) prompt.pos.X = px prompt.pos.Y = py end - elseif impact.Object and impact.Object.tag == "generatedImage" and impact.Distance < 100 then - print("Picked up the generated image at position", impact.Object.Position) - -- Logic to pick up the generated image if not already equipped - if not equippedImage then - equippedImage = impact.Object - -- Disable physics to "pick up" the object - impact.Object.Physics = PhysicsMode.Disabled - Player:EquipRightHand(impact.Object) - impact.Object.Scale = 0.5 -- Adjust scale if needed - - -- Inform server and other players that the object has been picked up - local e = Event() - e.action = "picked_object" - e.objectId = impact.Object.Id - e:SendTo(Server, OtherPlayers) - - sfx("pick_up_sound", {Position = impact.Object.Position}) -- Adjust sound effect as needed - end - elseif impact.Object and impact.Object.tag == "opponent" and equippedImage then - print("Used equipped image on opponent at position", impact.Object.Position) - -- Use the equipped image on the opponent - useImageOnOpponent(impact.Object, equippedImage) - equippedImage = nil -- Reset the equipped image reference - else - print("No action for this object") - itemDetails = ui:createFrame(Color(0,0,0,128)) - local line1 = ui:createText(impact.Object.userInput, Color.White) - line1.object.MaxWidth = 200 - line1:setParent(itemDetails) - local line2 = ui:createText("by " .. impact.Object.user, Color.White, "small") - line2:setParent(itemDetails) - - itemDetails.parentDidResize = function() - local width = math.max(line1.Width, line2.Width) + PADDING * 2 - local height = line1.Height + line2.Height + PADDING * 3 - itemDetails.Width = width - itemDetails.Height = height - line1.pos = {PADDING, itemDetails.Height - PADDING - line1.Height, 0} - line2.pos = line1.pos - {0, line2.Height + PADDING, 0} - - local px = screenPos.X - itemDetails.Width * 0.5 - if px < Screen.SafeArea.Left + PADDING then px = Screen.SafeArea.Left + PADDING end - if px > Screen.Width - Screen.SafeArea.Right - itemDetails.Width - PADDING then px = Screen.Width - Screen.SafeArea.Right - itemDetails.Width - PADDING end - - local py = screenPos.Y + POINTER_OFFSET - if py < Screen.SafeArea.Bottom + PADDING then py = Screen.SafeArea.Bottom + PADDING end - if py > Screen.Height - Screen.SafeArea.Top - itemDetails.Height - PADDING then py = Screen.Height - Screen.SafeArea.Top - itemDetails.Height - PADDING end - - itemDetails.pos = {px, py, 0} - end + + else -- clicked on generated item + + itemDetails = ui:createFrame(Color(0,0,0,128)) + local line1 = ui:createText(impact.Object.userInput, Color.White) + line1.object.MaxWidth = 200 + line1:setParent(itemDetails) + local line2 = ui:createText("by " .. impact.Object.user, Color.White, "small") + line2:setParent(itemDetails) + + itemDetails.parentDidResize = function() + local width = math.max(line1.Width, line2.Width) + PADDING * 2 + local height = line1.Height + line2.Height + PADDING * 3 + itemDetails.Width = width + itemDetails.Height = height + line1.pos = {PADDING, itemDetails.Height - PADDING - line1.Height, 0} + line2.pos = line1.pos - {0, line2.Height + PADDING, 0} + + local px = screenPos.X - itemDetails.Width * 0.5 + if px < Screen.SafeArea.Left + PADDING then px = Screen.SafeArea.Left + PADDING end + if px > Screen.Width - Screen.SafeArea.Right - itemDetails.Width - PADDING then px = Screen.Width - Screen.SafeArea.Right - itemDetails.Width - PADDING end + + local py = screenPos.Y + POINTER_OFFSET + if py < Screen.SafeArea.Bottom + PADDING then py = Screen.SafeArea.Bottom + PADDING end + if py > Screen.Height - Screen.SafeArea.Top - itemDetails.Height - PADDING then py = Screen.Height - Screen.SafeArea.Top - itemDetails.Height - PADDING end + + itemDetails.pos = {px, py, 0} + end itemDetails:parentDidResize() + end end end -function useImageOnOpponent(opponentObject, imageObject) - -- Send an event to the server to process the encounter - local e = Event() - e.action = "resolve_encounter" - local author, name = splitAtFirst(opponentObject.ItemName, '.') -- Assuming this contains the "opponent" name - e.opponentName = name - e.image = imageObject.userInput -- Assuming this contains the "tool" used - e:SendTo(Server) - - -- Optionally, remove the image from the player's hand immediately or wait for server response - imageObject:RemoveFromParent() -end - -function spawnRandomOpponent() - -- Randomly select an opponent from the Config.Items list - local opponentIndex = math.random(1, #opponentsList) - local opponentKey = opponentsList[opponentIndex] - - -- Instantiate the selected opponent shape - local opponentShape = Shape(Items[opponentIndex]) - - -- Set opponent properties and add it to the world - opponentShape:SetParent(World) - opponentShape.Position = Player.Position - opponentShape.IsHidden = false - opponentShape.tag = "opponent" - opponentShape.name = opponentKey - opponentShape.healthIndex = opponentIndex - - -- Add to collision group - opponentShape.CollisionGroups = Map.CollisionGroups - return opponentShape -end - function showTarget(impact, pos) if impact == nil then return end @@ -538,8 +394,8 @@ function dropPlayer(p) end -- shows instructions at the top left corner of the screen -function showInstructions() - if instructions ~= nil then +function showInstructions() + if instructions ~= nil then instructions:show() return end @@ -572,20 +428,20 @@ end function showWelcomeHint() if welcomeHint ~= nil then return end welcomeHint = ui:createText("Click on a block!", Color(1.0,1.0,1.0), "big") - welcomeHint.parentDidResize = function() + welcomeHint.parentDidResize = function() welcomeHint.pos.X = Screen.Width * 0.5 - welcomeHint.Width * 0.5 - welcomeHint.pos.Y = Screen.Height * 0.66 - welcomeHint.Height * 0.5 + welcomeHint.pos.Y = Screen.Height * 0.66 - welcomeHint.Height * 0.5 end welcomeHint:parentDidResize() local t = 0 - welcomeHint.object.Tick = function(o, dt) + welcomeHint.object.Tick = function(o, dt) t = t + dt - if (t % 0.4) <= 0.2 then - o.Color = Color(0.0, 0.8, 0.6) - else - o.Color = Color(1.0, 1.0, 1.0) - end + if (t % 0.4) <= 0.2 then + o.Color = Color(0.0, 0.8, 0.6) + else + o.Color = Color(1.0, 1.0, 1.0) + end end end @@ -636,122 +492,4 @@ function makeBubble(e) end) gens[e.id] = bubble -end - -function splitAtFirst(inputString, delimiter) - local pos = string.find(inputString, delimiter, 1, true) - if pos then - return string.sub(inputString, 1, pos - 1), string.sub(inputString, pos + 1) - else - return inputString - end -end - - --- HP Bars - --- create and attach HP bar to given player -attachHPBar = function(obj) - local frame = Shape(Items.petroglyph.hp_bar) - obj:AddChild(frame) - frame.LocalPosition = { 0, 35, 4.5 } - frame.LocalRotation = { 0, math.pi * .5, 0 } -- item was drawn along Z - if hpBarMaxLength == nil then - hpBarMaxLength = frame.Depth - 2 - end - - local hp = Shape(Items.petroglyph.block_red) - frame:AddChild(hp) - hp.Pivot.Z = 0 - hp.LocalPosition.Z = -hpBarMaxLength * .5 - hp.LocalScale.Z = hpBarMaxLength - hp.IsHidden = true - - local hpFull = Shape(Items.petroglyph.block_green) - frame:AddChild(hpFull) - hpFull.LocalScale.Z = hpBarMaxLength - - print("Attaching HP bar to player at index: " .. obj.healthIndex .. " player name is " .. obj.name) - hpBar = { - player = obj, - startScale = hpBarMaxLength, - targetScale = hpBarMaxLength, - timer = 0, - hpShape = hp, - hpFullShape = hpFull, - frameShape = frame - } - print("hpBar length: " .. hpBar.hpShape.LocalScale.Z) - hpBars[obj.healthIndex + 1] = hpBar -end - --- remove HP bar from player -removeHPBar = function(player) - hpBars[player.healthIndex + 1] = {} -end - --- sets target HP for given player, animated by default -setHPBar = function(player, hpValue, isAnimated) - local hp = hpBars[player.healthIndex + 1] - local v = clamp(hpValue / playerMaxHP, 0, 1) - if isAnimated or isAnimated == nil then - print("Animating HP bar") - hp.startScale = hp.hpShape.LocalScale.Z - hp.targetScale = hpBarMaxLength * v - hp.timer = playerHPDuration - else - print("Setting HP bar without animation") - if v == 1.0 then - hp.hpFullShape.IsHidden = false - hp.hpShape.IsHidden = true - else - hp.hpFullShape.IsHidden = true - hp.hpShape.IsHidden = false - hp.hpShape.LocalScale.Z = hpBarMaxLength * v - end - hp.timer = 0 - end -end - --- update all HP bars animation -updateHPBars = function (dt) - local hp = nil - for i=1,#hpBars,1 do - hp = hpBars[i] - if hp.timer ~= nil and hp.timer > 0 then - hp.timer = hp.timer - dt - - local delta = hp.targetScale - hp.startScale - local v = clamp(1 - hp.timer / playerHPDuration, 0, 1) - hp.hpShape.LocalScale.Z = hp.startScale + delta * v - - local isFull = hp.hpShape.LocalScale.Z == hpBarMaxLength - hp.hpFullShape.IsHidden = not isFull - hp.hpShape.IsHidden = isFull - - if delta < 0 then - hp.frameShape.LocalPosition.X = playerHPNegativeShake * math.sin(60 * hp.timer) - else - hp.frameShape.LocalPosition.Y = 26 + playerHPPositiveBump * math.sin(v * math.pi) - end - end - end -end - - -clamp = function(value, min, max) - if value < min then - return min - elseif value > max then - return max - else - return value - end -end - - --- HP bars settings -playerMaxHP = 100 -playerHPDuration = .5 -- update animation in seconds -playerHPNegativeShake = .3 -playerHPPositiveBump = .6 \ No newline at end of file +end \ No newline at end of file diff --git a/cubzh/scripts/mod_test.lua b/cubzh/scripts/mod_test.lua new file mode 100644 index 0000000..47988c4 --- /dev/null +++ b/cubzh/scripts/mod_test.lua @@ -0,0 +1,602 @@ + +-- This is Cubzh's default world script. +-- We'll provide more templates later on to cover specific use cases like FPS games, data storage, synchornized shapes, etc. +-- Cubzh dev team and devs from the community will be happy to help you on Discord if you have questions: discord.gg/cubzh + +Config = { + -- using item as map + Map = "buche.playground_heights", + -- items that are going to be loaded before startup + Items = { + "jacksbertox.crate" + } +} + +local YOUR_API_TOKEN = "H4gjL-e9kvLF??2pz6oh=kJL497cBnsyCrQFdVkFadUkLnIaEamroYHb91GywMXrbGeDdmTiHxi8EqmJduCKPrDnfqWsjGuF0JJCUTrasGcBfGx=tlJCjq5q8jhVHWL?krIE74GT9AJ7qqX8nZQgsDa!Unk8GWaqWcVYT-19C!tCo11DcLvrnJPEOPlSbH7dDcXmAMfMEf1ZwZ1v1C9?2/BjPDeiAVTRlLFilwRFmKz7k4H-kCQnDH-RrBk!ZHl7" +local API_URL = "https://0074-195-154-25-43.ngrok-free.app" +local TRIGGER_AREA_SIZE = Number3(60, 30, 60) + +-- Client.OnStart is the first function to be called when the world is launched, on each user's device. +Client.OnStart = function() + + -- Setting up the ambience (lights) + -- other possible presets: + -- - ambience.dawn + -- - ambience.dusk + -- - ambience.midnight + -- The "ambience" module also accepts + -- custom settings (light colors, angles, etc.) + local ambience = require("ambience") + ambience:set(ambience.dusk) + + sfx = require("sfx") + -- There's only one AudioListener, but it can be placed wherever you want: + Player.Head:AddChild(AudioListener) + + -- Requiring "multi" module is all you need to see other players in your game! + -- (remove this line if you want to be solo) + multi = require("multi") + + -- This function drops the local player above the center of the map: + dropPlayer = function() + Player.Position = Number3(Map.Width * 0.5, Map.Height + 10, Map.Depth * 0.5) * Map.Scale + Player.Rotation = { 0, 0, 0 } + Player.Velocity = { 0, 0, 0 } + end + + -- Add player to the World (root scene Object) and call dropPlayer(). + World:AddChild(Player) + dropPlayer() + + dialog = require("dialog") + dialog:setMaxWidth(400) + + avatar = require("avatar") + + ease = require("ease") + updateLocationTimer = nil + character = nil + engineId = nil + + -- SYNCED ACTIONS + multi:onAction("swingRight", function(sender) + sender:SwingRight() + end) + npcDataClient = {} + + timer = Timer(1, false, function() + _helpers.createNPCsAndLocations() + local e = Event() + e.action = "registerEngine" + e:SendTo(Server) + end) +end + +-- jump function, triggered with Action1 +-- (space bar on PC, button 1 on mobile) +Client.Action1 = function() + if Player.IsOnGround then + Player.Velocity.Y = 100 + sfx("hurtscream_1", {Position = Player.Position, Volume = 0.4}) + end + local e = Event() + e.action = "stepMainCharacter" + e.actionType = "JUMP" + e:SendTo(Server) +end + +-- Function to calculate distance between two positions +local function calculateDistance(pos1, pos2) + local dx = pos1.X - pos2.x + local dy = pos1.Y - pos2.y + local dz = pos1.Z - pos2.z + return math.sqrt(dx*dx + dy*dy + dz*dz) +end + +function findClosestLocation(playerPosition, locationData) + -- Assume `playerPosition` holds the current position of the player + local closestLocation = nil + local smallestDistance = math.huge -- Large initial value + + for _, location in pairs(locationData) do + local distance = calculateDistance(playerPosition, location.position) + if distance < smallestDistance then + smallestDistance = distance + closestLocation = location + end + end + + if closestLocation then + -- Closest location found, now send its ID to update the character's location + return closestLocation + end +end + +-- Client.Tick is executed up to 60 times per second on player's device. +Client.Tick = function(dt) + -- Detect if player is falling and use dropPlayer() when it happens! + if Player.Position.Y < -500 then + dropPlayer() + -- It's funnier with a message. + Player:TextBubble("💀 Oops!", true) + end +end + +-- Triggered when posting message with chat input +Client.OnChat = function(payload) + -- <0.0.52 : "payload" was a string value. + -- 0.0.52+ : "payload" is a table, with a "message" key + local msg = type(payload) == "string" and payload or payload.message + + Player:TextBubble(msg, 3, true) + sfx("waterdrop_2", {Position = Player.Position, Pitch = 1.1 + math.random() * 0.5}) + + local e = Event() + e.action = "stepMainCharacter" + e.actionType = "SAY" + e.content = msg + e:SendTo(Server) +end + +-- Pointer.Click is called following click/touch down & up events, +-- without draging the pointer in between. +-- Let's use this function to add a few interactions with the scene! +Pointer.Click = function(pointerEvent) + + -- Cast a ray from pointer event, + -- do different things depending on what it hits. + local impact = pointerEvent:CastRay() + if impact ~= nil then + if impact.Object == Player then + -- clicked on local player -> display message + little jump + Player:TextBubble("Easy, I'm ticklish! 😬", 1.0, true) + sfx("waterdrop_2", {Position = Player.Position, Pitch = 1.1 + math.random() * 0.5}) + Player.Velocity.Y = 50 + end + end +end + + +Client.DidReceiveEvent = function(e) + if e.action == "NPCActionResponse" then + _helpers.parseAction(e) + elseif e.action == "mainCharacterCreated" then + -- Setup a new timer to delay the next update call + characterId = e.character._id + updateLocationTimer = Timer(0.5, true, function() + local e = Event() + e.action = "updateCharacterLocation" + e.position = Player.Position + e.characterId = characterId + e:SendTo(Server) + end) + -- print("Character ID: " .. character._id) + elseif e.action == "NPCRegistered" then + -- Update NPC in the client side table to add the _id + for _, npc in pairs(npcDataClient) do + print("Checking NPC " .. npc.name) + if npc.name == e.npcName then + print("Assigning ID " .. e.npcId .. " to NPC " .. npc.name) + npc._id = e.npcId + end + end + end +end + + +------------------------------------------------------------------------------------------------ +-- Server Stuff --------------------------------------------------------------------------------- +------------------------------------------------------------------------------------------------ + +-- Server -------------------------------------------------------------------------------------- +Server.OnStart = function() + -- Initialize tables to hold NPC and location data + engineId = nil + locationId = nil + character = nil + npcData = {} + locationData = {} +end + +Server.DidReceiveEvent = function(e) + if e.action == "registerNPC" then + registerNPC(e.avatarId, e.physicalDescription, e.psychologicalProfile, e.currentLocationName) + elseif e.action == "registerLocation" then + registerLocation(e.name, e.position, e.description) + elseif e.action == "registerEngine" then + print("Registering engine...") + registerEngine(e.Sender) + elseif e.action == "stepMainCharacter" then + stepMainCharacter(character, engineId, e.actionType, npcData["aduermael"]._id, npcData["aduermael"].name, e.content) + elseif e.action == "updateCharacterLocation" then + closest = findClosestLocation(e.position, locationData) + -- if closest._id is different from the current location, update the character's location + if closest._id ~= character.current_location._id then + updateCharacterLocation(engineId, e.characterId, closest._id) + end + else + print("Unknown action received.") + end +end + +Server.Tick = function(dt) +end + +Server.OnPlayerJoin = function(player) +end + +------------------------------------------------------------------------------------------------ +-- Helpers -------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------------------ +_helpers = {} + + +_helpers.lookAt = function(obj, target) + if not target then + ease:linear(obj, 0.1).Forward = obj.initialForward + obj.Tick = nil + return + end + obj.Tick = function(self, _) + _helpers.lookAtHorizontal(self, target) + end +end + +_helpers.lookAtHorizontal = function(o1, o2) + local n3_1 = Number3.Zero + local n3_2 = Number3.Zero + n3_1:Set(o1.Position.X, 0, o1.Position.Z) + n3_2:Set(o2.Position.X, 0, o2.Position.Z) + ease:linear(o1, 0.1).Forward = n3_2 - n3_1 +end + +_helpers.createNPC = function(avatarId, physicalDescription, psychologicalProfile, currentLocationName, currentPosition) + -- Create the NPC's Object and Avatar + local NPC = {} + NPC.object = Object() + World:AddChild(NPC.object) + NPC.object.Position = currentPosition or Number3(0, 0, 0) + NPC.object.Scale = 0.5 + NPC.object.Physics = PhysicsMode.Trigger + NPC.object.CollisionBox = Box({ + -TRIGGER_AREA_SIZE.Width * 0.5, + math.min(-TRIGGER_AREA_SIZE.Height, NPC.object.CollisionBox.Min.Y), + -TRIGGER_AREA_SIZE.Depth * 0.5, + }, { + TRIGGER_AREA_SIZE.Width * 0.5, + math.max(TRIGGER_AREA_SIZE.Height, NPC.object.CollisionBox.Max.Y), + TRIGGER_AREA_SIZE.Depth * 0.5, + }) + NPC.object.OnCollisionBegin = function(self, other) + if other ~= Player then + return + end + _helpers.lookAt(self.avatarContainer, other) + end + NPC.object.OnCollisionEnd = function(self, other) + if other ~= Player then + return + end + _helpers.lookAt(self.avatarContainer, nil) + end + + local container = Object() + container.Rotation = NPC.object.Rotation + container.initialRotation = NPC.object.Rotation:Copy() + container.initialForward = NPC.object.Forward:Copy() + container:SetParent(NPC.object) + container.Physics = PhysicsMode.Dynamic + NPC.object.avatarContainer = container + + local avatar = require("avatar") + NPC.avatar = avatar:get(avatarId) + NPC.avatar:SetParent(NPC.object.avatarContainer) + + -- Register it + local e = Event() + e.action = "registerNPC" + e.avatarId = avatarId + e.physicalDescription = physicalDescription + e.psychologicalProfile = psychologicalProfile + e.currentLocationName = currentLocationName + e:SendTo(Server) + return NPC +end + +_helpers.createLocation = function(name, position, description) + local e = Event() + e.action = "registerLocation" + e.name = name + e.position = position + e.description = description + e:SendTo(Server) +end + +_helpers.createNPCsAndLocations = function() + -- Example location registration + local loc1 = _helpers.createLocation( + "Medieval Inn", + Number3(130, 23, 75), + "An inn lost in the middle of the forest, where travelers can rest and eat." + ) + local loc2 = _helpers.createLocation( + "Abandoned temple", + Number3(303, 20, 263), + "Lost deep inside the woods, this temple features a mysterious altar statue. Fresh fruits and offrands are scattered on the ground." + ) + local loc3 = _helpers.createLocation( + "Lone grave in the woods", + Number3(142, 20, 258), + "Inside a small clearing in the forest lies a stone cross, marking the grave of a lost soul." + ) + local loc4 = _helpers.createLocation( + "Rope bridge", + Number3(26, 20, 301), + "Near the edge of a cliff, a rope bridge connects the forest to the island. The bridge is old and fragile, but still usable." + ) + local loc5 = _helpers.createLocation( + "Forest entrance", + Number3(156, 20, 168), + "The entrance to the forest is marked by a large stone arch. The path is wide and well maintained." + ) + + local NPC1 = _helpers.createNPC("aduermael", "Tall, with green eyes", "Friendly and helpful", "Medieval Inn", Number3(130, 23, 75)) + table.insert(npcDataClient, {name = "aduermael", avatar = NPC1.avatar, object = NPC1.object}) + NPC1.avatar.Animations.SwingRight:Play() + local NPC2 = _helpers.createNPC("soliton", "Short, with a big nose", "Grumpy and suspicious", "Abandoned temple", Number3(303, 20, 263)) + table.insert(npcDataClient, {name = "soliton", avatar = NPC2.avatar, object = NPC2.object}) + local NPC3 = _helpers.createNPC("caillef", "Tall, with a big beard", "Wise and mysterious", "Lone grave in the woods", Number3(142, 20, 258)) + table.insert(npcDataClient, {name = "caillef", avatar = NPC3.avatar, object = NPC3.object}) +end + +_helpers.findNPCById = function(id) + for _, npc in pairs(npcDataClient) do + if npc._id == id then + return npc + end + end +end + +_helpers.parseAction = function(action) + local npc = _helpers.findNPCById(action.protagonistId) + print("Parsing action for NPC with name: " .. npc.name) + if action.actionType == "GREET" then + -- TODO: face action.target and wave hand + dialog:create("", npc.avatar) + npc.avatar.Animations.SwingRight:Play() + elseif action.actionType == "SAY" then + dialog:create(action.content, npc.avatar) + elseif action.actionType == "JUMP" then + dialog:create("", npc.avatar) + npc.object.avatarContainer.Velocity.Y = 50 + timer = Timer(1, false, function() + npc.object.avatarContainer.Velocity.Y = 50 + end) + elseif action.actionType == "MOVE" then + -- TODO + elseif action.actionType == "FOLLOW" then + -- TODO + end +end + +------------------------------------------------------------------------------------------------ +-- Gigax Stuff --------------------------------------------------------------------------------- +------------------------------------------------------------------------------------------------ + +-- Function to create and register an NPC +function registerNPC(avatarId, physicalDescription, psychologicalProfile, currentLocationName) + -- Add NPC to npcData table + npcData[avatarId] = { + name = avatarId, + physical_description = physicalDescription, + psychological_profile = psychologicalProfile, + current_location_name = currentLocationName, + skills = { + { + name = "say", + description = "Say smthg out loud", + parameter_types = {"character", "content"} + }, + -- { + -- name = "move", + -- description = "Move to a new location", + -- parameter_types = {"location"} + -- }, + { + name = "greet", + description = "Greet a character by waving your hand at them", + parameter_types = {"character"} + }, + -- { + -- name = "follow", + -- description = "Follow a character around for a while", + -- parameter_types = {"character"} + -- }, + { + name = "jump", + description = "Jump in the air", + } + } + } +end + +-- Function to register a location +function registerLocation(name, position, description) + locationData[name] = { + position = {x = position._x, y = position._y, z = position._z}, + name = name, + description = description + } +end + +function registerEngine(sender) + local apiUrl = API_URL .. "/api/engine/company/" + print("Updating engine with NPC data...") + local headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = YOUR_API_TOKEN + } + + -- Prepare the data structure expected by the backend + local engineData = { + name = "mod_test", + NPCs = {}, + locations = {} -- Populate if you have dynamic location data similar to NPCs + } + + for _, npc in pairs(npcData) do + table.insert(engineData.NPCs, { + name = npc.name, + physical_description = npc.physical_description, + psychological_profile = npc.psychological_profile, + current_location_name = npc.current_location_name, + skills = npc.skills + }) + end + + -- Populate locations + for _, loc in pairs(locationData) do + table.insert(engineData.locations, { + name = loc.name, + position = loc.position, + description = loc.description + }) + end + + local body = JSON:Encode(engineData) + + HTTP:Post(apiUrl, headers, body, function(res) + if res.StatusCode ~= 201 then + print("Error updating engine: " .. res.StatusCode) + return + end + -- Decode the response body to extract engine and location IDs + local responseData = JSON:Decode(res.Body) + + -- Save the engine_id for future use + engineId = responseData.engine.id + print("Engine ID: " .. engineId) + + -- Saving all the _ids inside locationData table: + for _, loc in pairs(responseData.locations) do + locationData[loc.name]._id = loc._id + end + + -- same for characters: + for _, npc in pairs(responseData.NPCs) do + npcData[npc.name]._id = npc._id + local e = Event() + e.action = "NPCRegistered" + e.npcName = npc.name + e.npcId = npc._id + e.engineId = engineId + e:SendTo(sender) + end + + + registerMainCharacter(engineId, locationData["Medieval Inn"]._id, sender) + -- print the location data as JSON + end) +end + +function registerMainCharacter(engineId, locationId, sender) + -- Example character data, replace with actual data as needed + local newCharacterData = { + name = "oncheman", + physical_description = "A human playing the game", + current_location_id = locationId, + position = {x = 0, y = 0, z = 0} + } + + -- Serialize the character data to JSON + local jsonData = JSON:Encode(newCharacterData) + + local headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = YOUR_API_TOKEN + } + + local apiUrl = API_URL .. "/api/character/company/main?engine_id=" .. engineId + + -- Make the HTTP POST request + HTTP:Post(apiUrl, headers, jsonData, function(response) + if response.StatusCode ~= 200 then + print("Error creating or fetching main character: " .. response.StatusCode) + end + print("Main character created/fetched successfully.") + character = JSON:Decode(response.Body) + local e = Event() + e.action = "mainCharacterCreated" + e.character = character + e:SendTo(sender) + end) +end + +function stepMainCharacter(character, engineId, actionType, targetId, targetName, content) + -- Now, step the character + local stepUrl = API_URL .. "/api/character/" .. character._id .. "/step-no-ws?engine_id=" .. engineId + local stepActionData = { + character_id = character._id, -- Use the character ID from the creation/fetch response + action_type = actionType, + target = targetId, + target_name = targetName, + content = content + } + local stepJsonData = JSON:Encode(stepActionData) + print("Stepping character with data: " .. stepJsonData) + + local headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = YOUR_API_TOKEN + } + -- You might need to adjust headers or use the same if they include the needed Authorization + HTTP:Post(stepUrl, headers, stepJsonData, function(stepResponse) + if stepResponse.StatusCode ~= 200 then + -- print("Error stepping character: " .. stepResponse.StatusCode) + return + end + + local actions = JSON:Decode(stepResponse.Body) + -- Find the target character by id using the "target" field in the response: + for _, action in ipairs(actions) do + local e = Event() + e.action = "NPCActionResponse" + e.actionType = action.action_type + e.content = action.content + for _, npc in pairs(npcData) do + if action.character_id == npc._id then + -- Perform the action on the target character + e.protagonistId = npc._id + elseif action.target == npc._id then + -- Perform the action on the target character + e.targetId = npc._id + end + end + e:SendTo(Players) + end + end) +end + +function updateCharacterLocation(engineId, characterId, locationId) + local updateData = { + -- Fill with necessary character update information + current_location_id = locationId + } + + local jsonData = JSON:Encode(updateData) + local headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = YOUR_API_TOKEN + } + + -- Assuming `characterId` and `engineId` are available globally or passed appropriately + local apiUrl = API_URL .. "/api/character/" .. characterId .. "?engine_id=" .. engineId + + HTTP:Post(apiUrl, headers, jsonData, function(response) + if response.StatusCode ~= 200 then + print("Error updating character location: " .. response.StatusCode) + else + character = JSON:Decode(response.Body) + end + end) +end \ No newline at end of file diff --git a/cubzh/scripts/mod_test_pathfinding.lua b/cubzh/scripts/mod_test_pathfinding.lua new file mode 100644 index 0000000..13f3385 --- /dev/null +++ b/cubzh/scripts/mod_test_pathfinding.lua @@ -0,0 +1,932 @@ + +-- This is Cubzh's default world script. +-- We'll provide more templates later on to cover specific use cases like FPS games, data storage, synchornized shapes, etc. +-- Cubzh dev team and devs from the community will be happy to help you on Discord if you have questions: discord.gg/cubzh + +Config = { + -- using item as map + Map = "buche.playground_heights", + -- items that are going to be loaded before startup + Items = { + "jacksbertox.crate" + } +} + +local CRATE_START_POSITION = Number3(382, 290, 153) +local GUY_START_POSITION = Number3(37, 3, 36) +local OFFSET_Y = Number3(0, 1, 0) +local OFFSET_XZ = Number3(0.5, 0, 0.5) +local YOUR_API_TOKEN = "H4gjL-e9kvLF??2pz6oh=kJL497cBnsyCrQFdVkFadUkLnIaEamroYHb91GywMXrbGeDdmTiHxi8EqmJduCKPrDnfqWsjGuF0JJCUTrasGcBfGx=tlJCjq5q8jhVHWL?krIE74GT9AJ7qqX8nZQgsDa!Unk8GWaqWcVYT-19C!tCo11DcLvrnJPEOPlSbH7dDcXmAMfMEf1ZwZ1v1C9?2/BjPDeiAVTRlLFilwRFmKz7k4H-kCQnDH-RrBk!ZHl7" +local API_URL = "https://0074-195-154-25-43.ngrok-free.app" +local multi = require("multi") +local ease = require("ease") + +-- Client.OnStart is the first function to be called when the world is launched, on each user's device. +Client.OnStart = function() + + -- Setting up the ambience (lights) + -- other possible presets: + -- - ambience.dawn + -- - ambience.dusk + -- - ambience.midnight + -- The "ambience" module also accepts + -- custom settings (light colors, angles, etc.) + local ambience = require("ambience") + ambience:set(ambience.noon) + + -- The sfx module can be used to play spatialized sounds in one line calls. + -- A list of available sounds can be found here: + -- https://docs.cu.bzh/guides/quick/adding-sounds#list-of-available-sounds + sfx = require("sfx") + -- There's only one AudioListener, but it can be placed wherever you want: + Player.Head:AddChild(AudioListener) + + -- Requiring "multi" module is all you need to see other players in your game! + -- (remove this line if you want to be solo) + require("multi") + + -- This function drops the local player above the center of the map: + dropPlayer = function() + Player.Position = Number3(Map.Width * 0.5, Map.Height + 10, Map.Depth * 0.5) * Map.Scale + Player.Rotation = { 0, 0, 0 } + Player.Velocity = { 0, 0, 0 } + end + + -- Add player to the World (root scene Object) and call dropPlayer(). + World:AddChild(Player) + dropPlayer() + + -- A Shape is an object made out of cubes. + -- Let's instantiate one with one of our imported items: + crate = Shape(Items.jacksbertox.crate) + World:AddChild(crate) + crate.Physics = PhysicsMode.StaticPerBlock + -- By default, the pivot is at the center of the bounding box, + -- but since want to place this one with ground positions, let's + -- move the pivot to the bottom: + crate.Pivot.Y = 0 + crate.Position = CRATE_START_POSITION + crateOwner = nil + + dialog = require("dialog") + dialog:setMaxWidth(400) + + avatar = require("avatar") + -- Create an avatarId to avatar mapping + avatarIdToAvatar = { + } +end + +LocalEvent:Listen(LocalEvent.Name.OnPlayerJoin, function(p) + print("Player joined") + -- _initPathfinding(p) +end) + +LocalEvent:Listen(LocalEvent.Name.Tick, function(dt) + if _config == nil then + return + end + -- _cameraFollow(_config.camera, Player) + -- _cameraHideBlockingObjects(_config.camera) + -- _moveNoPhysics(dt) +end) + +-- INIT FUNCTIONS +_initPathfinding = function(_, p) + Client.DirectionalPad = nil + Client.AnalogPad = nil + Pointer.Drag = nil + + if not _config then + _setConfig() + end + print("Initializing player") + _initializePlayer(p, _config.camera) + if not p == Player then + return + end + print("Initializing map") + _initializeMap(_config.map) +end + +-- jump function, triggered with Action1 +-- (space bar on PC, button 1 on mobile) +Client.Action1 = function() + if Player.IsOnGround then + Player.Velocity.Y = 100 + sfx("hurtscream_1", {Position = Player.Position, Volume = 0.4}) + end + + o = Object() + o:SetParent(World) + o.Position = GUY_START_POSITION + {0, 0, 3} + o.Scale = 0.5 + o.Physics = PhysicsMode.Dynamic + a = avatar:get("aduermael") + a:SetParent(o) + avatarIdToAvatar["aduermael"] = a + + local e = Event() + e.action = "registerNPC" + e.avatarId = "aduermael" + e.physicalDescription = "Tall, with green eyes" + e.psychologicalProfile = "Friendly and helpful" + e.currentLocationName = "Medieval Inn" + e:SendTo(Server) + + NPC1 = createNPC("aduermael", "Tall, with green eyes", "Friendly and helpful", "Medieval Inn") + NPC2 = createNPC("soliton", "Short, with a big nose", "Grumpy and suspicious", "Abandoned temple") + NPC3 = createNPC("caillef", "Tall, with a big beard", "Wise and mysterious", "Lone grave in the woods") + + local e = Event() + e.action = "testRegisterEngine" + e:SendTo(Server) +end + + +function createNPC(avatarId, physicalDescription, psychologicalProfile, currentLocationName) + -- Create the NPC's Object and Avatar + NPC = {} + NPC.object = Object() + NPC.object.SetParent(World) + NPC.object.Position = GUY_START_POSITION + {0, 0, 3} + NPC.object.Scale = 0.5 + NPC.object.Physics = PhysicsMode.Dynamic + + NPC.avatar = avatar:get("aduermael") + NPC.avatar:SetParent(NPC.object) + avatarIdToAvatar["aduermael"] = NPC.avatar + + -- Register it + local e = Event() + e.action = "registerNPC" + e.avatarId = avatarId + e.physicalDescription = physicalDescription + e.psychologicalProfile = psychologicalProfile + e.currentLocationName = currentLocationName + e:SendTo(Server) + return NPC +end + + +-- Client.Tick is executed up to 60 times per second on player's device. +Client.Tick = function(dt) + -- Detect if player is falling and use dropPlayer() when it happens! + if Player.Position.Y < -500 then + dropPlayer() + -- It's funnier with a message. + Player:TextBubble("💀 Oops!", true) + end +end + +-- Triggered when posting message with chat input +Client.OnChat = function(payload) + -- <0.0.52 : "payload" was a string value. + -- 0.0.52+ : "payload" is a table, with a "message" key + local msg = type(payload) == "string" and payload or payload.message + + Player:TextBubble(msg, 3, true) + sfx("waterdrop_2", {Position = Player.Position, Pitch = 1.1 + math.random() * 0.5}) + + local e = Event() + e.action = "stepMainCharacter" + e.content = msg + e:SendTo(Server) + +end + +-- Pointer.Click is called following click/touch down & up events, +-- without draging the pointer in between. +-- Let's use this function to add a few interactions with the scene! +Pointer.Click = function(pointerEvent) + + -- Cast a ray from pointer event, + -- do different things depending on what it hits. + local impact = pointerEvent:CastRay() + if impact ~= nil then + if impact.Object == Player then + -- clicked on local player -> display message + little jump + Player:TextBubble("Easy, I'm ticklish! 😬", 1.0, true) + sfx("waterdrop_2", {Position = Player.Position, Pitch = 1.1 + math.random() * 0.5}) + Player.Velocity.Y = 50 + + elseif impact.Object == Map and impact.Block ~= nil then + -- clicked on Map block -> display block info + + -- making an exception if player is holding the crate to place it + if crateEquiped and crateOwner == Player.ID and impact.FaceTouched == Face.Top then + -- The position of the impact can be computed from the origin of the ray + -- (pointerEvent.Position here), its direction (pointerEvent.Direction), + -- and the distance: + local impactPosition = pointerEvent.Position + pointerEvent.Direction * impact.Distance + + Player:EquipRightHand(nil) + World:AddChild(crate) + crate.Physics = PhysicsMode.StaticPerBlock + crate.Scale = 1 + crate.Pivot.Y = 0 + crate.Position = impactPosition + crateEquiped = false + + -- send event to inform server and other players that + -- crate as been placed. Of course this code is not + -- needed if you turn off multiplayer. + local e = Event() + e.action = "place_crate" + e.owner = Player.ID + e.pos = crate.Position + e:SendTo(Server, OtherPlayers) + + sfx("wood_impact_1", {Position = crate.Position}) + + crateOwner = nil + + return + end + + local b = impact.Block + local t = Text() + t.Text = string.format("coords: %d,%d,%d\ncolor: %d,%d,%d", + b.Coords.X, b.Coords.Y, b.Coords.Z, + b.Color.R, b.Color.G, b.Color.B) + t.FontSize = 44 + t.Type = TextType.Screen -- display text in screen space + t.BackgroundColor = Color(0,0,0,0) -- transparent + t.Color = Color(255,255,255) + World:AddChild(t) + + local blockCenter = b.Coords + {0.5,0.5,0.5} + -- convert block coordinates to world position: + t.Position = impact.Object:BlockToWorld(blockCenter) + + -- Timer to request text removal in 1 second + Timer(1.0, function() + t:RemoveFromParent() + end) + + _pathTo(pointerEvent, o) + + elseif not crateEquiped and impact.Object == crate then + if impact.Distance < 80 then + + crate.Physics = PhysicsMode.Disabled + Player:EquipRightHand(crate) + crate.Scale = 0.5 + crateEquiped = true + crateOwner = Player.ID + + -- inform server and other players + -- that crate has been picked + local e = Event() + e.action = "picked_crate" + e:SendTo(Server, OtherPlayers) + + sfx("wood_impact_5", {Position = crate.Position, Pitch = 1.2}) + + else + Player:TextBubble("I'm too far to grab it!", 1, true) + sfx("waterdrop_2", {Position = Player.Position, Pitch = 1.1 + math.random() * 0.5}) + end + end + end +end + + +Client.DidReceiveEvent = function(e) + if e.action == "displayDialog" then + dialog:create(e.content, avatarIdToAvatar[e.avatarId]) + end +end + + +------------------------------------------------------------------------------------------------ +-- Server Stuff --------------------------------------------------------------------------------- +------------------------------------------------------------------------------------------------ + +-- Server -------------------------------------------------------------------------------------- +Server.OnStart = function() + -- Initialize tables to hold NPC and location data + engineId = nil + locationId = nil + character = nil + npcData = {} + locationData = {} +end + +Server.DidReceiveEvent = function(e) + if e.action == "registerNPC" then + registerNPC(e.avatarId, e.physicalDescription, e.psychologicalProfile, e.currentLocationName) + elseif e.action == "testRegisterEngine" then + -- Example location registration + registerLocation( + "Medieval Inn", + Vector3(23,3,13), + "An inn lost in the middle of the forest, where travelers can rest and eat." + ) + registerLocation( + "Abandoned temple", + Vector3(60,3,57), + "Lost deep inside the woods, this temple features a mysterious altar statue. Fresh fruits and offrands are scattered on the ground." + ) + registerLocation( + "Lone grave in the woods", + Vector3(28,3,53), + "Inside a small clearing in the forest lies a stone cross, marking the grave of a lost soul." + ) + registerLocation( + "Rope bridge", + Vector3(4,3,63), + "Near the edge of a cliff, a rope bridge connects the forest to the island. The bridge is old and fragile, but still usable." + ) + registerLocation( + "Forest entrance", + Vector3(32,3,30), + "The entrance to the forest is marked by a large stone arch. The path is wide and well maintained." + ) + registerEngine() + elseif e.action == "stepMainCharacter" then + stepMainCharacter(character, engineId, npcData["aduermael"]._id, npcData["aduermael"].name, e.content) + end + -- if e.action == "interactWithNPC" then + -- -- Assume e contains NPC identifier and the player's action + -- local apiUrl = "https://yourbackend.com/api/simulation/step" -- Adjust to your actual endpoint + -- local headers = { + -- ["Content-Type"] = "application/json", + -- ["Authorization"] = "Bearer YOUR_API_TOKEN" + -- } + + -- local actionData = { + -- npcId = e.npcId, + -- action = e.actionType, + -- playerId = e.Sender.ID + -- } + + -- local body = JSON:Encode(actionData) + + -- HTTP:Post(apiUrl, headers, body, function(res) + -- if res.StatusCode ~= 200 then + -- print("Error stepping simulation: " .. res.StatusCode) + -- return + -- end + -- local updates = JSON:Decode(res.Body) + -- -- Handle the updates, such as moving NPCs, changing dialogue, etc. + -- -- You might need to send events back to the client to reflect these updates + -- end) + -- end +end + +Server.OnPlayerJoin = function(player) +end + +------------------------------------------------------------------------------------------------ +-- Gigax Stuff --------------------------------------------------------------------------------- +------------------------------------------------------------------------------------------------ + +-- Function to create and register an NPC +function registerNPC(avatarId, physicalDescription, psychologicalProfile, currentLocationName) + -- Add NPC to npcData table + npcData[avatarId] = { + name = avatarId, + physical_description = physicalDescription, + psychological_profile = psychologicalProfile, + current_location_name = currentLocationName, + skills = { + { + name = "say", + description = "Say smthg out loud", + parameter_types = {"character", "content"} + }, + { + name = "move", + description = "Move to a new location", + parameter_types = {"location"} + } + } + } +end + +-- Function to register a location +function registerLocation(name, position, description) + locationData[name] = { + position = {x = position._x, y = position._y, z = position._z}, + name = name, + description = description + } +end + +function registerEngine() + local apiUrl = API_URL .. "/api/engine/company/" + print("Updating engine with NPC data...") + local headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = YOUR_API_TOKEN + } + + -- Prepare the data structure expected by the backend + local engineData = { + name = "mod_test", + NPCs = {}, + locations = {} -- Populate if you have dynamic location data similar to NPCs + } + + for _, npc in pairs(npcData) do + table.insert(engineData.NPCs, { + name = npc.name, + physical_description = npc.physical_description, + psychological_profile = npc.psychological_profile, + current_location_name = npc.current_location_name, + skills = npc.skills + }) + end + + -- Populate locations + for _, loc in pairs(locationData) do + table.insert(engineData.locations, { + name = loc.name, + position = loc.position, + description = loc.description + }) + end + + local body = JSON:Encode(engineData) + + HTTP:Post(apiUrl, headers, body, function(res) + if res.StatusCode ~= 201 then + print("Error updating engine: " .. res.StatusCode) + return + end + -- Decode the response body to extract engine and location IDs + local responseData = JSON:Decode(res.Body) + + -- Save the engine_id for future use + engineId = responseData.engine.id + + -- Saving all the _ids inside locationData table: + for _, loc in pairs(responseData.locations) do + locationData[loc.name]._id = loc._id + end + + -- same for characters: + for _, npc in pairs(responseData.NPCs) do + npcData[npc.name]._id = npc._id + end + + + character = registerMainCharacter(engineId, locationData["Village Entrance"]._id) + -- print the location data as JSON + end) +end + +function registerMainCharacter(engineId, locationId) + -- Example character data, replace with actual data as needed + local newCharacterData = { + name = "oncheman", + physical_description = "Tall, with green eyes", + current_location_id = locationId, + position = {x = 0, y = 0, z = 0} + } + + -- Serialize the character data to JSON + local jsonData = JSON:Encode(newCharacterData) + + local headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = YOUR_API_TOKEN + } + + local apiUrl = API_URL .. "/api/character/company/main?engine_id=" .. engineId + + -- Make the HTTP POST request + HTTP:Post(apiUrl, headers, jsonData, function(response) + if response.StatusCode ~= 200 then + print("Error creating or fetching main character: " .. response.StatusCode) + end + print("Main character created/fetched successfully.") + character = JSON:Decode(response.Body) + end) +end + +function stepMainCharacter(character, engineId, targetId, targetName, content) + + -- Now, step the character + local stepUrl = API_URL .. "/api/character/" .. character._id .. "/step-no-ws?engine_id=" .. engineId + local stepActionData = { + character_id = character._id, -- Use the character ID from the creation/fetch response + action_type = "SAY", + target = targetId, + target_name = targetName, + content = content + } + local stepJsonData = JSON:Encode(stepActionData) + print("Stepping character with data: " .. stepJsonData) + + local headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = YOUR_API_TOKEN + } + -- You might need to adjust headers or use the same if they include the needed Authorization + HTTP:Post(stepUrl, headers, stepJsonData, function(stepResponse) + if stepResponse.StatusCode ~= 200 then + -- print("Error stepping character: " .. stepResponse.StatusCode) + return + end + + local actions = JSON:Decode(stepResponse.Body) + print("Character stepped successfully.") + -- Find the target character by id using the "target" field in the response: + for _, action in ipairs(actions) do + for _, npc in pairs(npcData) do + if action.character_id == npc._id then + -- Perform the action on the target character + -- Process actions as needed + print("Target character: " .. npc.name) + local e = Event() + e.action = "displayDialog" + e.avatarId = npc.name + e.content = action.content + e:SendTo(Players) + end + end + end + end) +end + + +---------------------- +--module "pathfinding" +---------------------- +pathfinding = {} +local instance = {} +instance.camera = nil +instance.pfMap = nil +instance.pfPath = nil +instance.pfStep = nil +instance.cursorPath = {} +instance.cursors = {} +local defaultConfig = { + zoomMin = 50, + zoomMax = 150, + zoomSpeed = 1, + zoomDefault = 150, + cameraOffset = Number3(0, 0, 80), + cameraFocusY = 25, + moveSpeed = 50, + mapGroups = { 1 }, + playerGroups = { 2 }, + map = nil, + camera = nil, +} + +multi:onAction("showCursor", function(_, data) + _showCursor(Players[data.id], data.pos) +end) + + +_setConfig = function(conf) + config = require("config"):merge(defaultConfig, conf) + if not config.map then + config.map = Map + end + if not config.camera then + config.camera = Camera + end +end + +_initializePlayer = function(p, c) + if p == Player then + p.walkFX = particles:newEmitter({ + life = function() + return 0.3 + end, + velocity = function() + local v = Number3(5 + math.random() * 5, 5, 0) + v:Rotate(0, math.random() * math.pi * 2, 0) + return v + end, + color = function() + return Color(255, 255, 255, 128) + end, + acceleration = function() + return -Config.ConstantAcceleration + end, + collidesWithGroups = function() + return { 1 } + end, + }) + p.walkFX:SetParent(p) + p.walkFX.LocalPosition = { 0, 0, 0 } + end + -- _cursorCreate(p) +end + + +_initializeMap = function() + instance.pfMap = pathfinding.createPathfindingMap() +end + +-- Add defaultConfig, setConfig... +pathfinding.createPathfindingMap = function(config) -- Takes the map as argument + local defaultPathfindingConfig = { + map = Map, + pathHeight = 3, + pathLevel = 1, + obstacleGroups = { 3 }, + } + + _config = require("config"):merge(defaultPathfindingConfig, config) + + local map2d = {} --create a 2D map to store which blocks can be walked on with a height map + local box = Box({ 0, 0, 0 }, _config.map.Scale) --create a box the size of a block + local dist = _config.map.Scale.Y * _config.pathHeight -- check for ~3 blocks up + local dir = Number3.Up -- checking up + for x = 0, _config.map.Width do + map2d[x] = {} + for z = 0, _config.map.Depth do + local h = _config.pathLevel -- default height is the path level + for y = 0, _config.map.Height do + if _config.map:GetBlock(x, y, z) then + h = y + 1 + end -- adjust height by checking all blocks on the column to + end + box.Min = { x, h, z } * _config.map.Scale + box.Max = box.Min + _config.map.Scale + local data = h -- by default, store the height for the pathfinder + local impact = box:Cast(dir, dist, _config.obstacleGroups) -- check object above the retained height + if impact.Object ~= nil then + data = impact.Object + end -- if any, store the object for further use + map2d[x][z] = data + end + end + return map2d +end + +pathfinding.findPath = function(origin, destination, map) + local kCount = 500 + local diagonalsAllowed = true + local kClimb = 1 + + local directNeighbors = { + { x = -1, z = 0 }, + { x = 0, z = 1 }, + { x = 1, z = 0 }, + { x = 0, z = -1 }, + } + local diagonalNeighbours = { + { x = -1, z = 0 }, + { x = 0, z = 1 }, + { x = 1, z = 0 }, + { x = 0, z = -1 }, + { x = -1, z = -1 }, + { x = 1, z = -1 }, + { x = 1, z = 1 }, + { x = -1, z = 1 }, + } + + local createNode = function(x, y, z, parent) + local node = {} + node.x, node.y, node.z, node.parent = x, y, z, parent + return node + end + + local heuristic = function(x1, x2, z1, z2, y1, y2) + local dx = x1 - x2 + local dz = z1 - z2 + local dy = y1 - y2 + local h = dx * dx + dz * dz + dy * dy + return h + end + + local elapsed = function(parentNode) + return parentNode.g + 1 + end + + local calculateScores = function(node, endNode) + node.g = node.parent and elapsed(node.parent) or 0 + node.h = heuristic(node.x, endNode.x, node.z, endNode.z, node.y, endNode.y) + node.f = node.g + node.h + end + + local listContains = function(list, node) + for _, v in ipairs(list) do + if v.x == node.x and v.y == node.y and v.z == node.z then + return v + end + end + return false + end + + local getChildren = function(node, map) + local children = {} + local neighbors = diagonalsAllowed and diagonalNeighbours or directNeighbors + local parentHeight = map[node.x][node.z] + + for _, neighbor in ipairs(neighbors) do + local x = node.x + neighbor.x + local z = node.z + neighbor.z + local y = map[x][z] + if type(y) == "integer" and math.abs(y - parentHeight) <= kClimb then + table.insert(children, { x = x, y = y, z = z }) + end + end + return children + end + + -- Init lists to run the nodes & a count as protection for while + local openList = {} + local closedList = {} + local count = 0 + -- Setup startNode and endNode + local endNode = createNode(destination.X, destination.Y, destination.Z, nil) + local startNode = createNode(origin.X, origin.Y, origin.Z, nil) + -- Calculate starting node score + calculateScores(startNode, endNode) + -- Insert the startNode as first node to examine + table.insert(openList, startNode) + -- While there are nodes to examine and the count is under kCount (and the function did not return) + while #openList > 0 and count < kCount do + count = count + 1 + -- Sort openList with ascending f + table.sort(openList, function(a, b) + return a.f > b.f + end) + -- Examine the last node + local currentNode = table.remove(openList) + table.insert(closedList, currentNode) + if listContains(closedList, endNode) then + local path = {} + local current = currentNode + while current ~= nil do + table.insert(path, current) + current = current.parent + end + return path + end + -- Generate children based on map and test function + local children = getChildren(currentNode, map) + for _, child in ipairs(children) do + -- Create child node + local childNode = createNode(child.x, child.y, child.z, currentNode) + -- Check if it's already been examined + if not listContains(closedList, childNode) then + -- Check if it's already planned to be examined with a bigger f (meaning further away) + if not listContains(openList, childNode) then -- or self.listContains(openList, childNode).f > childNode.f then + calculateScores(childNode, endNode) + table.insert(openList, childNode) + end + end + end + end + print("No path found in " .. count .. " iterations") + return false +end + + + +_pathTo = function(pointerEvent, protagonist) + local impact = pointerEvent:CastRay(config.mapGroups) + if impact.Block == nil then + return + end + + local origin = Map:WorldToBlock(protagonist.Position) + origin = Number3(math.floor(origin.X), math.floor(origin.Y), math.floor(origin.Z)) + local destination = impact.Block.Position / Map.Scale + OFFSET_Y + local path = pathfinding.findPath(origin, destination, instance.pfMap) + if not path then + print("No path found") + return + end + print("Path found") + + instance.pfPath = path + instance.pfStep = #path - 1 --skipping the first block, which we're standing on + + destination = impact.Block.Position + (OFFSET_XZ + OFFSET_Y) * Map.Scale + -- _showCursor(Player, destination) + _showPath(instance.pfPath) + _followPath(protagonist, instance.pfPath, instance.pfStep) +end + +_showCursor = function(p, pos) + if p == Player then + multi:action("showCursor", { id = Player.ID, pos = pos }) + end + _cursorSet(instance.cursors[p.ID], pos) +end + +_followPath = function(p, path, idx) + if not path[idx] then + print("No path index found") + return + end + if instance.cursorPath[idx + 1] then + instance.cursorPath[idx + 1]:RemoveFromParent() + end + p.destination = (Number3(path[idx].x, path[idx].y, path[idx].z) + OFFSET_XZ) * Map.Scale + p.Forward = { p.destination.X - p.Position.X, 0, p.destination.Z - p.Position.Z } -- just to know where to face +end + +_cursorCreate = function(p) + ease = require("ease") + Object:Load("aduermael.selector", function(o) + o:SetParent(World) + o.Physics = PhysicsMode.Trigger + o.CollisionGroups = nil + o.CollidesWithGroups = config.playerGroups + o.Scale = 0 + + o.Tick = function(self, dt) + self:RotateLocal(0, dt, 0) + end + + o.OnCollisionBegin = function(self, _) + ease:cancel(self) + ease:outQuad(self, 0.5).Scale = { 0, 0, 0 } + end + + if p ~= Player then + o.PrivateDrawMode = 1 + end + + print("Player ID: " .. p.ID) + instance.cursors[p.ID] = o + end) +end + +_cursorSet = function(cursor, pos) + ease:cancel(cursor) + -- cursor.Position = pos + ease:outQuad(cursor, 0.5).Scale = CURSOR_SCALE +end + +_showPath = function(path) + local createPath = function(v) + local s = MutableShape() + s:AddBlock(Color.White, 0, 0, 0) + s.Pivot = { 0.5, 0.5, 0.5 } + s.Physics = PhysicsMode.Trigger + s.CollidesWithGroups = { 2 } + s.CollisionGroups = {} + s.OnCollisionBegin = function(self, _) + self:RemoveFromParent() + end + s:SetParent(World) + s.Position = (Number3(v.x, v.y, v.z) + OFFSET_XZ) * Map.Scale + + return s + end + + for _, v in pairs(instance.cursorPath) do + v:RemoveFromParent() + end + instance.cursorPath = {} + + for _, v in pairs(path) do + local c = createPath(v) + table.insert(instance.cursorPath, c) + end +end + +_moveNoPhysics = function(dt) + if Player.Physics then + Player.Physics = PhysicsMode.Trigger + end + if not instance.pfPath then + return + end + + local dest = Number3(Player.destination.X, 0, Player.destination.Z) + local pos = Number3(Player.Position.X, 0, Player.Position.Z) + local test = math.sqrt(2) + if (dest - pos).Length < test then --checking on a 2D plane only + Player.Position = Player.destination + instance.pfStep = instance.pfStep - 1 + if instance.pfPath[instance.pfStep] ~= nil then + _followPath(Player, instance.pfPath, instance.pfStep) + else + if Player.Animations.Idle.IsPlaying then + return + end + if Player.Animations.Walk.IsPlaying then + Player.Animations.Walk:Stop() + end + Player.Animations.Idle:Play() + end + else + _followPath(Player, instance.pfPath, instance.pfStep) + Player.moveDir = (Player.destination - Player.Position):Normalize() + Player.Position = Player.Position + Player.moveDir * config.moveSpeed * dt + local r = math.random() + if Player.walkFX and r > 0.8 then + Player.walkFX:spawn(1) + end + if Player.Animations.Walk.IsPlaying then + return + end + if Player.Animations.Idle.IsPlaying then + Player.Animations.Idle:Stop() + end + Player.Animations.Walk:Play() + end +end diff --git a/cubzh/scripts/pathfinding.lua b/cubzh/scripts/pathfinding.lua new file mode 100644 index 0000000..ffaf155 --- /dev/null +++ b/cubzh/scripts/pathfinding.lua @@ -0,0 +1,777 @@ +-- Modules +-- Pathfinding +-- Map loader +-- Ressources +-- Collect & Respawn +-- Craft +Config = { + Map = "buche.playground_heights", +} + +local UI_ICON_SIZE = 32 +local UI_MARGIN_SMALL = 4 + +localPlayerResources = { + ["Iron"] = 10, + ["Ash"] = 7, +} + +inventory = { + maxWeight = 500, +} + +resourcesList = { + ["Iron"] = { min = 0, level = 1, trade = "Miner", weight = 5, icon = "jacksbertox.iron_ingot" }, + ["Copper"] = { min = 0, level = 2, trade = "Miner", weight = 5, icon = "jacksbertox.iron_ingot" }, + ["Bronze"] = { min = 0, level = 3, trade = "Miner", weight = 5, icon = "jacksbertox.iron_ingot" }, + ["Cobalt"] = { min = 0, level = 4, trade = "Miner", weight = 5, icon = "jacksbertox.iron_ingot" }, + ["Tin"] = { min = 0, level = 5, trade = "Miner", weight = 5, icon = "jacksbertox.iron_ingot" }, + -- + ["Ash"] = { min = 0, level = 1, trade = "Lumberjack", weight = 5, icon = "voxels.log_2" }, + ["Chestnut"] = { min = 0, level = 2, trade = "Lumberjack", weight = 5, icon = "voxels.log_2" }, + ["Walnut"] = { min = 0, level = 3, trade = "Lumberjack", weight = 5, icon = "voxels.log_2" }, + ["Oak"] = { min = 0, level = 4, trade = "Lumberjack", weight = 5, icon = "voxels.log_2" }, + ["Maple"] = { min = 0, level = 5, trade = "Lumberjack", weight = 5, icon = "voxels.log_2" }, + -- + ["Wheat"] = { min = 0, level = 1, trade = "Farmer", weight = 2, icon = "pratamacam.corn" }, + ["Barley"] = { min = 0, level = 2, trade = "Farmer", weight = 2, icon = "pratamacam.corn" }, + ["Oats"] = { min = 0, level = 3, trade = "Farmer", weight = 2, icon = "pratamacam.corn" }, + ["Hop"] = { min = 0, level = 4, trade = "Farmer", weight = 2, icon = "pratamacam.corn" }, + ["Flax"] = { min = 0, level = 5, trade = "Farmer", weight = 2, icon = "pratamacam.corn" }, + -- + ["Nettles"] = { min = 0, level = 1, trade = "Alchemist", weight = 1, icon = "aduermael.leaf" }, + ["Sage"] = { min = 0, level = 2, trade = "Alchemist", weight = 1, icon = "aduermael.leaf" }, + ["Clover"] = { min = 0, level = 3, trade = "Alchemist", weight = 1, icon = "aduermael.leaf" }, + ["Mint"] = { min = 0, level = 4, trade = "Alchemist", weight = 1, icon = "aduermael.leaf" }, + ["Edelweiss"] = { min = 0, level = 5, trade = "Alchemist", weight = 1, icon = "aduermael.leaf" }, +} + +craftsList = { + ["Ferrite"] = { + trade = "Miner", + level = 2, + weight = 10, + ingredients = { + ["Iron"] = 10, + ["Copper"] = 10, + }, + }, + ["Loaf"] = { + trade = "Farmer", + level = 2, + weight = 4, + ingredients = { + ["Wheat"] = 10, + ["Barley"] = 10, + }, + }, +} + +uiUpdateResource = function(_, resourceName, amount) + local text = uiElements[resourceName] + text.Text = string.format("%d", amount) + text.pos = text.icon.pos + { -text.Width - 10, text.icon.Height * 0.5 - text.Height * 0.5, 0 } + if amount > 0 then + text.icon:show() + text:show() + else + text.icon:hide() + text:hide() + end +end + +Client.OnStart = function() + require("multi") + require("sfx") + require("textbubbles").displayPlayerChatBubbles = true + walkSFX = require("walk_sfx") + ui = require("uikit") + + cResources:init(resourcesList) + for k, _ in pairs(resourcesList) do + cResources:addResourceCallbacks(k, { + onChange = uiUpdateResource, + }) + end + + initMap() + initUI() + initPlayer(Player) + dropPlayer(Player, Number3(Map.Width * 0.5, Map.Height + 1, Map.Depth * 0.5), Number3(0, 0, 0)) + + --map = loadMap(0, 0) +end + +mapList = {} +initMap = function() + for i = 0, 3 do + mapList[i] = {} + end + mapList[0][0] = "buche.playground_heights" + mapList[0][1] = "buche.playground_heights" +end + +uiElements = {} +initUI = function() + local idx = 0 + for k, v in pairs(resourcesList) do + local i = idx + 1 + Object:Load(v.icon, function(obj) + local text = ui:createText(string.format("%d", v.min), Color.White, "default") + local icon = ui:createShape(obj, { spherized = true }) + icon.Size = UI_ICON_SIZE + text.icon = icon + text.displayIndex = i + uiElements[k] = text + text.parentDidResize = function(self) + self.icon.pos = { + Screen.Width - UI_MARGIN_SMALL - self.icon.Width, + Screen.Height - Screen.SafeArea.Top - (self.icon.Height + UI_MARGIN_SMALL) * self.displayIndex, + } + self.pos = self.icon.pos + { -self.Width - 10, self.Height * 0.5 - self.Height * 0.5, 0 } + end + text:parentDidResize() + end) + idx = idx + 1 + end +end + +initPlayer = function(p) + World:AddChild(p) + p.Head:AddChild(AudioListener) + p.Physics = true + walkSFX:register(p) +end + +dropPlayer = function(p, pos, rot) + p.Position = pos + p.Rotation = rot +end + +-- CRESOURCES MODULE +cResources = {} +local index = { + resourcesListeners = {}, +} + +local metatable = { + __index = index, + __metatable = false, +} +setmetatable(cResources, metatable) + +-- table of "name = { min, max, default }" +index.init = function(_, resourcesList) + index.resourcesList = resourcesList + for _, v in pairs(index.resourcesList) do + v.default = v.default or v.min + end +end + +-- config can be { max = 20 } to change the max value for example +index.updateResource = function(_, target, resourceName, config) + for k, v in pairs(config) do + target.cResources[resourceName][k] = v + end + if index.resourcesListeners[resourceName] then + for _, callbacks in ipairs(index.resourcesListeners[resourceName]) do + if callbacks.onChange then + callbacks.onChange(target, resourceName, target.cResources[resourceName].value) + end + end + end +end + +-- return a listener with a Remove function +-- callbacks is an array containing two functions (optional) +-- - onChange = function(target, resourceName, amount) +-- - onMinReach = function(target, resourceName, amount) +-- - onMaxReach = function(target, resourceName, amount) +index.addResourceCallbacks = function(_, resourceName, callbacks) + index.resourcesListeners[resourceName] = index.resourcesListeners[resourceName] or {} + table.insert(index.resourcesListeners[resourceName], callbacks) + return { + Remove = function() + for i = #index.resourcesListeners[resourceName], 1, -1 do + local item = index.resourcesListeners[resourceName][i] + if item == callbacks then + table.remove(index.resourcesListeners[resourceName], i) + return + end + end + end, + } +end + +index.add = function(_, target, resourceName, amount) + local resource = target.cResources[resourceName] + if resource.max and resource.value >= resource.max then + if index.resourcesListeners[resourceName] then + for _, callbacks in ipairs(index.resourcesListeners[resourceName]) do + if callbacks.onMaxReach then + callbacks.onMaxReach(target, resourceName, target.cResources[resourceName].value) + end + end + end + return false + end + + resource.value = math.floor(resource.value + amount) + + if resource.max and resource.value > resource.max then + resource.value = resource.max + if index.resourcesListeners[resourceName] then + for _, callbacks in ipairs(index.resourcesListeners[resourceName]) do + if callbacks.onMaxReach then + callbacks.onMaxReach(target, resourceName, resource.value) + end + end + end + end + + if index.resourcesListeners[resourceName] then + for _, callbacks in ipairs(index.resourcesListeners[resourceName]) do + if callbacks.onChange then + callbacks.onChange(target, resourceName, resource.value) + end + end + end + + -- TODO: save + return true +end + +index.remove = function(_, target, resourceName, amount) + local resource = target.cResources[resourceName] + local newValue = math.floor(resource.value - amount) + if resource.min and resource.value < resource.min then + if index.resourcesListeners[resourceName] then + for _, callbacks in ipairs(index.resourcesListeners[resourceName]) do + if callbacks.onMinReach then + callbacks.onMinReach(target, resourceName, resource.value) + end + end + end + return false + end + + resource.value = newValue + + if index.resourcesListeners[resourceName] then + for _, callbacks in ipairs(index.resourcesListeners[resourceName]) do + if callbacks.onChange then + callbacks.onChange(target, resourceName, resource.value) + end + end + end + + -- TODO: save + return true +end + +LocalEvent:Listen(LocalEvent.Name.OnPlayerJoin, function(p) + if not index.resourcesList then + --error("resourcesList is not defined") + return + end + p.cResources = {} + for name, v in pairs(index.resourcesList) do + p.cResources[name] = { + value = v.default, + default = v.default, + min = v.min, + max = v.max, + } + end + + for k, v in pairs(localPlayerResources) do + index:add(p, k, v) + end +end) +-- END CRESOURCES MODULE + +-- MODULE +-- POINT AND CLICK +-- Create a point and click mode with a topdown camera with movement mechanics including pathfinding +pointAndClick = {} + +local defaultConfig = { + zoomMin = 50, + zoomMax = 150, + zoomSpeed = 1, + zoomDefault = 150, + cameraOffset = Number3(0, 0, 80), + cameraFocusY = 25, + moveSpeed = 50, + mapGroups = { 1 }, + playerGroups = { 2 }, + map = nil, + camera = nil, +} + +local CURSOR_SCALE = Number3(0.5, 0.5, 0.5) +local OFFSET_XZ = Number3(0.5, 0, 0.5) +local OFFSET_Y = Number3(0, 1, 0) +local BOXCAST_DIST = 500 + +local multi = require("multi") +local ease = require("ease") +local particles = require("particles") + +local _config = nil +local instance = {} +instance.camera = nil +instance.pfMap = nil +instance.pfPath = nil +instance.pfStep = nil +instance.cursorPath = {} +instance.cursors = {} + +-- AUTO INTEGRATION +LocalEvent:Listen(LocalEvent.Name.OnPlayerJoin, function(p) + if type(Client.IsMobile) == "boolean" then + print("lol") + pointAndClick:init(p) + end +end) +LocalEvent:Listen(LocalEvent.Name.Tick, function(dt) + if _config == nil then + return + end + _cameraFollow(_config.camera, Player) + _cameraHideBlockingObjects(_config.camera) + _moveNoPhysics(dt) +end) +LocalEvent:Listen(LocalEvent.Name.PointerUp, function(pointerEvent) + if not instance.pfMap then + return + end + _pathTo(pointerEvent) +end) +LocalEvent:Listen(LocalEvent.Name.PointerWheel, function(value) + if not instance.camera then + return + end + _cameraZoom(instance.camera, value) +end) +LocalEvent:Listen(LocalEvent.Name.PointerDrag, function(pointerEvent) + if not instance.camera then + return + end + _cameraRotate(instance.camera, pointerEvent) +end) + +multi:onAction("showCursor", function(_, data) + _showCursor(Players[data.id], data.pos) +end) + +-- INIT FUNCTIONS +pointAndClick.init = function(_, p) + Client.DirectionalPad = nil + Client.AnalogPad = nil + Pointer.Drag = nil + + if not _config then + _setConfig() + end + _initializePlayer(p, _config.camera) + if not p == Player then + return + end + _initializeMap(_config.map) +end + +_setConfig = function(conf) + config = require("config"):merge(defaultConfig, conf) + if not config.map then + config.map = Map + end + if not config.camera then + config.camera = Camera + end +end + +_initializePlayer = function(p, c) + if p == Player then + print("init player") + instance.camera = _initializeCamera(c) + p.walkFX = particles:newEmitter({ + life = function() + return 0.3 + end, + velocity = function() + local v = Number3(5 + math.random() * 5, 5, 0) + v:Rotate(0, math.random() * math.pi * 2, 0) + return v + end, + color = function() + return Color(255, 255, 255, 128) + end, + acceleration = function() + return -Config.ConstantAcceleration + end, + collidesWithGroups = function() + return { 1 } + end, + }) + p.walkFX:SetParent(p) + p.walkFX.LocalPosition = { 0, 0, 0 } + end + _cursorCreate(p) +end + +_initializeMap = function() + instance.pfMap = pathfinding.createPathfindingMap() +end + +_initializeCamera = function(c) + Fog.On = false + -- c:SetModeFree() + -- c.zoom = defaultConfig.zoomDefault + return c +end + +-- CAMERA FUNCTIONS +_cameraZoom = function(c, value) + if c.zoom + config.zoomSpeed * value < config.zoomMin then + c.zoom = config.zoomMin + elseif c.zoom + config.zoomSpeed * value > config.zoomMax then + c.zoom = config.zoomMax + else + c.zoom = c.zoom + config.zoomSpeed * value + end +end + +_cameraFollow = function(c, t) + -- c.Position.X, c.Position.Z = t.Position.X + config.cameraOffset.X, t.Position.Z + config.cameraOffset.Z + -- c.Position.Y = c.zoom + -- c.Forward = Number3(t.Position.X, config.cameraFocusY, t.Position.Z) - c.Position +end + +_cameraRotate = function(c, pe) + c:RotateLocal({ 0, 1, 0 }, pe.DX * 0.01) +end + +_cameraHideBlockingObjects = function(c) + -- local box = Box(c.Position - Map.Scale, c.Position + Map.Scale) + -- local dist = math.min((c.Position - Player.Position).Length, BOXCAST_DIST) + -- local impact = box:Cast(c.Forward, dist, config.obstacleGroups) + -- if not impact.Object then + -- return + -- end + + -- impact.Object.PrivateDrawMode = 1 + -- if not impact.Object.drawTimer then + -- impact.Object.drawTimer = Timer(0.5, function() + -- impact.Object.PrivateDrawMode = 0 + -- impact.Object.drawTimer = nil + -- end) + -- end +end + +-- MOVEMENT FUNCTIONS +_pathTo = function(pointerEvent) + local impact = pointerEvent:CastRay(config.mapGroups) + if impact.Block == nil then + return + end + + local origin = Map:WorldToBlock(Player.Position) + origin = Number3(math.floor(origin.X), math.floor(origin.Y), math.floor(origin.Z)) + local destination = impact.Block.Position / Map.Scale + OFFSET_Y + local path = pathfinding.findPath(origin, destination, instance.pfMap) + if not path then + return + end + + instance.pfPath = path + instance.pfStep = #path - 1 --skipping the first block, which we're standing on + + destination = impact.Block.Position + (OFFSET_XZ + OFFSET_Y) * Map.Scale + _showCursor(Player, destination) + _showPath(instance.pfPath) + _followPath(Player, instance.pfPath, instance.pfStep) +end + +_showCursor = function(p, pos) + if p == Player then + multi:action("showCursor", { id = Player.ID, pos = pos }) + end + _cursorSet(instance.cursors[p.ID], pos) +end + +_followPath = function(p, path, idx) + if not path[idx] then + return + end + if instance.cursorPath[idx + 1] then + instance.cursorPath[idx + 1]:RemoveFromParent() + end + p.destination = (Number3(path[idx].x, path[idx].y, path[idx].z) + OFFSET_XZ) * Map.Scale + p.Forward = { p.destination.X - p.Position.X, 0, p.destination.Z - p.Position.Z } -- just to know where to face +end + +_cursorCreate = function(p) + ease = require("ease") + Object:Load("aduermael.selector", function(o) + o:SetParent(World) + o.Physics = PhysicsMode.Trigger + o.CollisionGroups = nil + o.CollidesWithGroups = config.playerGroups + o.Scale = 0 + + o.Tick = function(self, dt) + self:RotateLocal(0, dt, 0) + end + + o.OnCollisionBegin = function(self, _) + ease:cancel(self) + ease:outQuad(self, 0.5).Scale = { 0, 0, 0 } + end + + if p ~= Player then + o.PrivateDrawMode = 1 + end + + print("Player ID: " .. p.ID) + instance.cursors[p.ID] = o + end) +end + +_cursorSet = function(cursor, pos) + ease:cancel(cursor) + -- cursor.Position = pos + ease:outQuad(cursor, 0.5).Scale = CURSOR_SCALE +end + +_showPath = function(path) + local createPath = function(v) + local s = MutableShape() + s:AddBlock(Color.White, 0, 0, 0) + s.Pivot = { 0.5, 0.5, 0.5 } + s.Physics = PhysicsMode.Trigger + s.CollidesWithGroups = { 2 } + s.CollisionGroups = {} + s.OnCollisionBegin = function(self, _) + self:RemoveFromParent() + end + s:SetParent(World) + s.Position = (Number3(v.x, v.y, v.z) + OFFSET_XZ) * Map.Scale + + return s + end + + for _, v in pairs(instance.cursorPath) do + v:RemoveFromParent() + end + instance.cursorPath = {} + + for _, v in pairs(path) do + local c = createPath(v) + table.insert(instance.cursorPath, c) + end +end + +_moveNoPhysics = function(dt) + if Player.Physics then + Player.Physics = PhysicsMode.Trigger + end + if not instance.pfPath then + return + end + + local dest = Number3(Player.destination.X, 0, Player.destination.Z) + local pos = Number3(Player.Position.X, 0, Player.Position.Z) + local test = math.sqrt(2) + if (dest - pos).Length < test then --checking on a 2D plane only + Player.Position = Player.destination + instance.pfStep = instance.pfStep - 1 + if instance.pfPath[instance.pfStep] ~= nil then + _followPath(Player, instance.pfPath, instance.pfStep) + else + if Player.Animations.Idle.IsPlaying then + return + end + if Player.Animations.Walk.IsPlaying then + Player.Animations.Walk:Stop() + end + Player.Animations.Idle:Play() + end + else + _followPath(Player, instance.pfPath, instance.pfStep) + Player.moveDir = (Player.destination - Player.Position):Normalize() + Player.Position = Player.Position + Player.moveDir * config.moveSpeed * dt + local r = math.random() + if Player.walkFX and r > 0.8 then + Player.walkFX:spawn(1) + end + if Player.Animations.Walk.IsPlaying then + return + end + if Player.Animations.Idle.IsPlaying then + Player.Animations.Idle:Stop() + end + Player.Animations.Walk:Play() + end +end + +---------------------- +--module "pathfinding" +---------------------- +pathfinding = {} + +-- Add defaultConfig, setConfig... +pathfinding.createPathfindingMap = function(config) -- Takes the map as argument + local defaultPathfindingConfig = { + map = Map, + pathHeight = 3, + pathLevel = 1, + obstacleGroups = { 3 }, + } + + _config = require("config"):merge(defaultPathfindingConfig, config) + + local map2d = {} --create a 2D map to store which blocks can be walked on with a height map + local box = Box({ 0, 0, 0 }, _config.map.Scale) --create a box the size of a block + local dist = _config.map.Scale.Y * _config.pathHeight -- check for ~3 blocks up + local dir = Number3.Up -- checking up + for x = 0, _config.map.Width do + map2d[x] = {} + for z = 0, _config.map.Depth do + local h = _config.pathLevel -- default height is the path level + for y = 0, _config.map.Height do + if _config.map:GetBlock(x, y, z) then + h = y + 1 + end -- adjust height by checking all blocks on the column to + end + box.Min = { x, h, z } * _config.map.Scale + box.Max = box.Min + _config.map.Scale + local data = h -- by default, store the height for the pathfinder + local impact = box:Cast(dir, dist, _config.obstacleGroups) -- check object above the retained height + if impact.Object ~= nil then + data = impact.Object + end -- if any, store the object for further use + map2d[x][z] = data + end + end + return map2d +end + +pathfinding.findPath = function(origin, destination, map) + local kCount = 500 + local diagonalsAllowed = true + local kClimb = 1 + + local directNeighbors = { + { x = -1, z = 0 }, + { x = 0, z = 1 }, + { x = 1, z = 0 }, + { x = 0, z = -1 }, + } + local diagonalNeighbours = { + { x = -1, z = 0 }, + { x = 0, z = 1 }, + { x = 1, z = 0 }, + { x = 0, z = -1 }, + { x = -1, z = -1 }, + { x = 1, z = -1 }, + { x = 1, z = 1 }, + { x = -1, z = 1 }, + } + + local createNode = function(x, y, z, parent) + local node = {} + node.x, node.y, node.z, node.parent = x, y, z, parent + return node + end + + local heuristic = function(x1, x2, z1, z2, y1, y2) + local dx = x1 - x2 + local dz = z1 - z2 + local dy = y1 - y2 + local h = dx * dx + dz * dz + dy * dy + return h + end + + local elapsed = function(parentNode) + return parentNode.g + 1 + end + + local calculateScores = function(node, endNode) + node.g = node.parent and elapsed(node.parent) or 0 + node.h = heuristic(node.x, endNode.x, node.z, endNode.z, node.y, endNode.y) + node.f = node.g + node.h + end + + local listContains = function(list, node) + for _, v in ipairs(list) do + if v.x == node.x and v.y == node.y and v.z == node.z then + return v + end + end + return false + end + + local getChildren = function(node, map) + local children = {} + local neighbors = diagonalsAllowed and diagonalNeighbours or directNeighbors + local parentHeight = map[node.x][node.z] + + for _, neighbor in ipairs(neighbors) do + local x = node.x + neighbor.x + local z = node.z + neighbor.z + local y = map[x][z] + if type(y) == "integer" and math.abs(y - parentHeight) <= kClimb then + table.insert(children, { x = x, y = y, z = z }) + end + end + return children + end + + -- Init lists to run the nodes & a count as protection for while + local openList = {} + local closedList = {} + local count = 0 + -- Setup startNode and endNode + local endNode = createNode(destination.X, destination.Y, destination.Z, nil) + local startNode = createNode(origin.X, origin.Y, origin.Z, nil) + -- Calculate starting node score + calculateScores(startNode, endNode) + -- Insert the startNode as first node to examine + table.insert(openList, startNode) + -- While there are nodes to examine and the count is under kCount (and the function did not return) + while #openList > 0 and count < kCount do + count = count + 1 + -- Sort openList with ascending f + table.sort(openList, function(a, b) + return a.f > b.f + end) + -- Examine the last node + local currentNode = table.remove(openList) + table.insert(closedList, currentNode) + if listContains(closedList, endNode) then + local path = {} + local current = currentNode + while current ~= nil do + table.insert(path, current) + current = current.parent + end + return path + end + -- Generate children based on map and test function + local children = getChildren(currentNode, map) + for _, child in ipairs(children) do + -- Create child node + local childNode = createNode(child.x, child.y, child.z, currentNode) + -- Check if it's already been examined + if not listContains(closedList, childNode) then + -- Check if it's already planned to be examined with a bigger f (meaning further away) + if not listContains(openList, childNode) then -- or self.listContains(openList, childNode).f > childNode.f then + calculateScores(childNode, endNode) + table.insert(openList, childNode) + end + end + end + end + return false +end \ No newline at end of file diff --git a/cubzh/scripts/procedural_maze.lua b/cubzh/scripts/procedural_maze.lua new file mode 100644 index 0000000..5de5ffe --- /dev/null +++ b/cubzh/scripts/procedural_maze.lua @@ -0,0 +1,1089 @@ +Config = { + Items = { + -- the same as opponentsList + "kooow.white_yeti", + "kooow.small_boar", + "kooow.ribberhead_dino", + "kooow.purple_demon", + "kooow.lion", + "kooow.brown_bear", + "kooow.pinky_demon", + "kooow.purple_bat", + "kooow.beagle_dog", + "kooow.lava_golem", + "kooow.grey_blue_robot", + "kooow.snowman_with_broom", + "kooow.gorilla", + "kooow.young_piggy", + "kooow.blocky_giant", + "kooow.grey_flying_dragon", + "kooow.cactus_creature", + "kooow.cow", + -- becouse i cannot get name from Items + "claire.torch", + "voxels.gate", + "petroglyph.hp_bar", + "petroglyph.block_red", + "petroglyph.block_green", + "tantris.craft_item", + "tantris.craft_button", + "xavier.win", + } +} + +local opponentsList = { + "kooow.white_yeti", + "kooow.small_boar", + "kooow.ribberhead_dino", + "kooow.purple_demon", + "kooow.lion", + "kooow.brown_bear", + "kooow.pinky_demon", + "kooow.purple_bat", + "kooow.beagle_dog", + "kooow.lava_golem", + "kooow.grey_blue_robot", + "kooow.snowman_with_broom", + "kooow.gorilla", + "kooow.young_piggy", + "kooow.blocky_giant", + "kooow.grey_flying_dragon", + "kooow.cactus_creature", + "kooow.cow", +} + +-- becouse i cannot use https://docs.cu.bzh/reference/items - local s = Shape(Items[ "aduermael.pumpkin" ]) +local randomOpponent = {} +local miniOpponents = {} +local miniHeight = 40 +local miniWidth = 40 + +-- DEBUG +local DEBUG = false + +-- Corridor stuff +local NUMBER_OF_CORRIDORS = #opponentsList +local CORRIDOR_LENGTH = 150 +local WALL_HEIGHT = 40 +local TORCH_SPACING = 40 +local corridors = {} + +-- Door stuff +local PADDING = 8 -- used for UI elements +local doors = {} + +-- HP bars settings +hpBars = {} -- may be we can use only 2 parameter player and opponent +playerMaxHP = 100 +playerHPDuration = .5 -- update animation in seconds +playerHPNegativeShake = .3 +playerHPPositiveBump = .6 +damageToApply = nil + +-- Misc +local BROWN = Color(139, 69, 19) +local POINTER_OFFSET = 20 +local GENERATED_ITEM_COLLISION_GROUP = 5 +faceNormals = { + [Face.Back] = Number3(0, 0, -1), [Face.Bottom] = Number3(0, -1, 0), [Face.Front] = Number3(0, 0, 1), + [Face.Left] = Number3(-1, 0, 0), [Face.Right] = Number3(1, 0, 0), [Face.Top] = Number3(0, 1, 0) +} + +Client.OnStart = function() + -- Add player to game + World:AddChild(Player, true) + + -- Initialize the corridors and gates + makeMap() + + -- Set up the UI + ease = require "ease" + ui = require "uikit" + sfx = require "sfx" + controls = require "controls" + controls:setButtonIcon("action1", "⬆️") + hierarchyActions = require "hierarchyactions" + + -- ui : score, opponent.name, miniOpponent + uiScore = ui:createText("", Color.White) + uiScore.pos.X = (Screen.Width - Screen.SafeArea.Right - uiScore.Width - PADDING) / 2 + uiScore.pos.Y = Screen.Height - Screen.SafeArea.Top - uiScore.Height - PADDING + + uiOpponent = ui:createText("", Color.White) + uiOpponent.pos.X = 0 + uiOpponent.pos.Y = Screen.SafeArea.Top + uiOpponent.Height + PADDING + + if (Screen.Width / NUMBER_OF_CORRIDORS < miniWidth) then + miniWidth = Screen.Width / NUMBER_OF_CORRIDORS + end + local node = ui:createNode() + for i = 1, NUMBER_OF_CORRIDORS, 1 do + mOp = ui:createShape(Shape(Items[i])) + mOp.Height = miniHeight + mOp.Width = miniWidth + mOp.pos.X = (i - 1) * miniWidth + mOp:setParent(node) + table.insert(miniOpponents, mOp) + end + + -- restartButton = ui:Button("Restart!") + -- restartButton.OnRelease = initGame + initGame() +end + +function initGame() + paused = false + + -- Game variable init start + level = 0 + generatedImage = nil + gens = {} + + uiScore.Text = "Level: " .. level + + -- HP bars array init + for i = 1, NUMBER_OF_CORRIDORS + 1, 1 do + hpBars[i] = {} + end + + -- Start Information + showInstructions() + showWelcomeHint() + + -- bad idea, but i cannot add opponentList to Items and cannot mix or fint element of opponentList in Items.... + createRandomSpawnList() + opponent = spawnRandomOpponent() + + -- update door + closeAllDoor() + + dropPlayer(Player) + Player.healthIndex = 0 + Player.name = "player" + attachHPBar(Player) + + for i = 1, NUMBER_OF_CORRIDORS, 1 do + miniOpponents[i]:show() + end + + -- Player.IsHidden = false + -- Pointer:Hide() + -- UI.Crosshair = true +end + +function endGame(action) + paused = true + -- Player.IsHidden = true + -- Pointer:Show() + -- UI.Crosshair = false + + -- restartButton:Add(Anchor.HCenter, Anchor.VCenter) + + initGame() +end + +Pointer.Click = function(pointerEvent) + hideWelcomeHint() + hideInstructions() + showMenu(pointerEvent) +end + +Client.OnPlayerJoin = function(p) + if p == Player then + return + end + dropPlayer(p) +end + +Client.Tick = function(dt) + if paused then + return + end + -- Detect if player is falling, + -- drop it above the map when it happens. + if Player.Position.Y < -500 then + dropPlayer(Player) + Player:TextBubble("💀 Oops!") + end + updateHPBars(dt) + launchToolOnOpponent(dt) +end + +function dropPlayer(p) + World:AddChild(p) + p.Position = Number3(corridors[1].Position.X + 10, 10, corridors[1].Position.Z + 20) + -- Rotate player 90 degres to the right to face the corridor + p.Rotation = { 0, math.pi / 2, 0 } + p.Velocity = { 0, 0, 0 } +end + +function cancelMenu() + -- hideMenu() + -- showInstructions() +end + +-- Function to create and set up the map +function closeAllDoor() + for i = 1, NUMBER_OF_CORRIDORS do + if doors[i].closed == false then + doors[i].closed = true + doors[i].Rotation = { 0, doors[i].rotClosed, 0 } + end + end +end + +function makeMap() + for i = 1, NUMBER_OF_CORRIDORS do + corridors[i] = makeCorridor((i - 1) * CORRIDOR_LENGTH, i) + end + + winTextSet(doors[NUMBER_OF_CORRIDORS].Position.X, + doors[NUMBER_OF_CORRIDORS].Position.Y / 2, + doors[NUMBER_OF_CORRIDORS].Position.Z / 2, + 2, 0, math.pi / 2, 0) +end + +function winTextSet(x, y, z, scale, x_rot, y_rot, z_rot) + wintext = Shape(Items.xavier.win) + wintext.Position = { x, y, z } + wintext.Rotation = { x_rot, y_rot, z_rot } + wintext.Scale = scale + wintext.CollisionGroups = { GENERATED_ITEM_COLLISION_GROUP } + corridors[1]:AddChild(wintext) +end + +function createDoorAtEndOfCorridor(offset, ix) + local door = Shape(Items.voxels.gate) + door.Position = Number3(offset + CORRIDOR_LENGTH, door.Height / 2, door.Width) + door.Rotation = Number3(0, math.pi / 2, 0) -- Adjusted to face the corridor + door.Pivot = { 0, door.Height * 0.5, door.Depth * 0.5 } + door.isDoor = true + door.number = ix -- If you have more doors, increment this number for each + door.closed = true + door.rotClosed = math.pi / 2 -- The rotation when the door is closed + doors[door.number] = door + World:AddChild(door) -- for what? if we attach it to corridor + return door +end + +function makeCorridor(offset, ix) + local corridor = Object() + corridor.CollisionGroups = { GENERATED_ITEM_COLLISION_GROUP } + World:AddChild(corridor) + + -- Add a door at the end of the corridor + doors[ix] = createDoorAtEndOfCorridor(offset, ix) + corridor:AddChild(doors[ix]) + + local CORRIDOR_WIDTH = doors[ix].Width + -- Create floor + local floor = MutableShape() + for x = offset, offset + CORRIDOR_LENGTH - 1 do + for z = 1, CORRIDOR_WIDTH - 2 do + -- Subtracting 2 since we're building walls on both sides + floor:AddBlock(BROWN, x, 0, z) + end + end + floor.CollisionGroups = { GENERATED_ITEM_COLLISION_GROUP } + corridor:AddChild(floor) + + -- Create walls + local leftWall = MutableShape() + local rightWall = MutableShape() + for x = offset, offset + CORRIDOR_LENGTH - 1 do + for y = 1, WALL_HEIGHT do + -- Left wall + leftWall:AddBlock(BROWN, x, y, 0) + -- Right wall + rightWall:AddBlock(BROWN, x, y, CORRIDOR_WIDTH - 1) + end + end + leftWall.CollisionGroups = { GENERATED_ITEM_COLLISION_GROUP } + rightWall.CollisionGroups = { GENERATED_ITEM_COLLISION_GROUP } + corridor:AddChild(leftWall) + corridor:AddChild(rightWall) + + -- Add torches alongside the walls, spaced evenly + for x = offset, offset + CORRIDOR_LENGTH - 1, TORCH_SPACING do + -- Left wall torches + local leftTorch = addTorch(x, 10, 4) -- Position adjusted for the left wall + -- Right wall torches + local rightTorch = addTorch(x, 10, CORRIDOR_WIDTH - 5) -- Position adjusted for the right wall + corridor:AddChild(leftTorch) + corridor:AddChild(rightTorch) + end + + -- Add the "Craft Button" sign on the Left wall + local infoPanel = Shape(Items.tantris.craft_item) + infoPanel.Position = Number3(offset + 15, 15, CORRIDOR_WIDTH - 1) -- Position adjusted for scale + -- Scale it down to 1/2 size + infoPanel.Scale = 0.5 + corridor:AddChild(infoPanel) + + local craftButton = Shape(Items.tantris.craft_button) + craftButton.Position = Number3(offset + 27, 9, CORRIDOR_WIDTH - 2) -- Position adjusted for scale + craftButton.isCraftButton = true + -- Add it to the collision group + craftButton.CollisionGroups = { GENERATED_ITEM_COLLISION_GROUP } + corridor:AddChild(craftButton) + + return corridor +end + +function addTorch(x, y, z) + local torch = Shape(Items.claire.torch) + torch.Position = Number3(x, y, z) -- Position adjusted for scale + return torch +end + +-- door stuff +function openDoor(doorNumber) + local door = doors[doorNumber] + if door and door.isDoor and door.closed then + doorAction(door, "open") + end +end + +-- Function to perform actions on doors +function doorAction(object, action) + if action == "toggle" then + object.closed = not object.closed + elseif action == "close" then + object.closed = true + elseif action == "open" then + object.closed = false + end + + -- Play sound effects based on the action + if object.closed then + sfx("doorclose_1", object.Position, 0.5) + else + sfx("dooropen_1", object.Position, 0.5) + end + + -- Set physics to trigger to allow for interaction, but not immediately solid + object.Physics = PhysicsMode.Trigger + object.colliders = 0 + + -- Set the door's rotation based on whether it's open or closed + if object.closed then + object.Rotation = { 0, object.rotClosed, 0 } + else + object.Rotation = { 0, object.rotClosed + math.pi * 0.5, 0 } + end +end + +-- "Craft Menu" (Just a text input UI for now) +function displayCraftMenu(pointerEvent, impact, pos) + local screenPos = Number2(pointerEvent.X * Screen.Width, pointerEvent.Y * Screen.Height) + + -- Modify the position to be at y = 1 and z - 3 vs the pos + pos = Number3(pos.X, 1, pos.Z - 6) + showTarget(impact, pos) + + prompt = ui:createNode() + local input = ui:createTextInput(nil, "What do you want?") + input:setParent(prompt) + input:focus() + + local send = function() + if input.Text ~= "" then + imageQuery(input.Text, impact, pos) + sfx("modal_3", { Spatialized = false, Pitch = 2.0 }) + end + prompt:remove() + prompt = nil + deleteTarget() + end + + input.onSubmit = send + + local sendBtn = ui:createButton("✅", { textSize = "big" }) + sendBtn:setParent(prompt) + sendBtn.onRelease = send + + input.Height = sendBtn.Height + input.Width = 250 + sendBtn.pos.X = input.Width + + local width = input.Width + sendBtn.Width + local height = sendBtn.Height + + local px = screenPos.X - width * 0.5 + if px < Screen.SafeArea.Left + PADDING then + px = Screen.SafeArea.Left + PADDING + end + if px > Screen.Width - Screen.SafeArea.Right - width - PADDING then + px = Screen.Width - Screen.SafeArea.Right - width - PADDING + end + + local py = screenPos.Y + POINTER_OFFSET + if py < Screen.SafeArea.Bottom + PADDING then + py = Screen.SafeArea.Bottom + PADDING + end + if py > Screen.Height - Screen.SafeArea.Top - height - PADDING then + py = Screen.Height - Screen.SafeArea.Top - height - PADDING + end + + prompt.pos.X = px + prompt.pos.Y = py +end + +-- Image query +function imageQuery(message, impact, pos) + if impact then + pos = pos + { 0, 1, 0 } + + local e = Event() + e.id = math.floor(math.random() * 1000000) + e.pos = pos + e.rotY = Player.Rotation.Y + e.action = "requestImageGeneration" -- This is the important bit; it tells the server to generate an image + local _, name = splitAtFirst(opponent.ItemName, '.') -- Assuming this contains the "opponent" name + e.opponentName = name + e.userInput = message + e.m = message + e:SendTo(Server) + + local e2 = Event() + e2.action = "otherGen" + e2.id = e.id + e2.m = message + e2.pos = e.pos + e2.rotY = e.rotY + e2:SendTo(OtherPlayers) + + makeBubble(e2) + end +end + +-- Spawn an opponent from the opponentsList +function shuffleList(list) + math.randomseed(os.time()) -- init random generator from time (like C++) + local len = #list + for i = len, 2, -1 do + local j = math.random(i) + list[i], list[j] = list[j], list[i] + end + return list +end + +function createRandomSpawnList() + for i = 1, NUMBER_OF_CORRIDORS, 1 do + randomOpponent[i] = i + end + shuffleList(randomOpponent) +end + +function spawnRandomOpponent() + -- Randomly select an opponent from the Config.Items list + local opponentIndex = randomOpponent[level + 1] -- math.random(1, #opponentsList) + local opponentKey = opponentsList[opponentIndex] + + local opponentShape = Shape(Items[opponentIndex], { includeChildren = true }) + + -- Like above, but iterating over all children to find the lowest y value in child.BoundingBox.Min.Y + local minY = 1000 + local minX = 1000 + local minZ = 1000 + -- using hierarchyActions + hierarchyActions:applyToDescendants(opponentShape, function(sh) + local bb = sh:ComputeWorldBoundingBox() + if bb.Min.Y < minY then + minY = bb.Min.Y + end + if bb.Min.X < minX then + minX = bb.Min.X + end + end) + + -- Set opponent properties and add it to the world + -- opponentShape:SetParent(World) + World:AddChild(opponentShape) + + opponentShape.Position = { doors[level + 1].Position.X + minX, -minY, (doors[level + 1].Position.Z - opponentShape.Width) / 2 } + opponentShape.Rotation = { 0, -math.pi / 2, 0 } + opponentShape.IsHidden = false + opponentShape.tag = "opponent" + opponentShape.name = opponentKey + -- split name to get only name without prefix + local _, opponentName = splitAtFirst(opponentKey, '.') + opponentShape.healthIndex = opponentIndex + + --Add to collision group + opponentShape.CollisionGroups = Map.CollisionGroups + attachHPBar(opponentShape) + uiOpponent.Text = "Current opponent: " .. opponentName + return opponentShape +end + +Client.DidReceiveEvent = function(e) + if paused then + return + end + if e.action == "imageIsGenerated" then + handleGeneratedImage(e) + elseif e.action == "damageToApply" then + damageToApply = e + end +end + +function handleGeneratedImage(e) + local pos + local rotY + + local bubble = gens[e.id] + if bubble then + pos = bubble.Position:Copy() + rotY = bubble.Rotation.Y + bubble.Tick = nil + if bubble.text then + bubble.text:RemoveFromParent() + end + bubble:RemoveFromParent() + gens[e.id] = nil + if e.vox == nil then + print("sorry, request failed!") + return + end + elseif e.pos then + pos = e.pos + rotY = e.rotY + else + return + end + + local success = pcall(function() + if JSON:Encode(e.vox)[1] == "{" then + print(JSON:Encode(e.vox)) + return + end + local s = Shape(e.vox) + s.CollisionGroups = Map.CollisionGroups + s.tag = "generatedImage" -- Tagging the object as a generated image + s.userInput = e.userInput + s.user = e.user + s:SetParent(World) + + -- first block is not at 0,0,0 + -- use collision box min to offset the pivot + local collisionBoxMin = s.CollisionBox.Min + local center = s.CollisionBox.Center:Copy() + + center.Y = s.CollisionBox.Min.Y + s.Pivot = { s.Width * 0.5 + collisionBoxMin.X, + 0 + collisionBoxMin.Y, + s.Depth * 0.5 + collisionBoxMin.Z } + s.Position = pos + s.Rotation.Y = rotY + s.Physics = PhysicsMode.Dynamic + + Timer(1, function() + s.Physics = PhysicsMode.TriggerPerBlock + s.CollisionGroups = { GENERATED_ITEM_COLLISION_GROUP } + firing = true + end) + generatedImage = s + sfx("waterdrop_2", { Position = pos }) + end) + if not success then + sfx("twang_2", { Position = pos }) + end +end + +function weaponAction(action) + if generatedImage then + generatedImage:RemoveFromParent() + end + generatedImage = nil + print("" .. action) + firing = false +end + +launchToolOnOpponent = function(dt) + if firing and generatedImage then + if opponent.IsHidden then + weaponAction("there is no opponent") -- don't work???? + elseif (opponent.Position - generatedImage.Position).Length > CORRIDOR_LENGTH then + weaponAction("opponent is too far") + Player:TextBubble("opponent is too far. Use another button") + else + local targetPosition = opponent.Position -- Assume 'opponent' is accessible and has a position + local animationTime = 1.0 -- Duration to reach the target, adjust as needed + ease:outSine(generatedImage, animationTime).Position = targetPosition + + -- Check if the tool has reached the opponent or if you need to stop firing + generatedImage.OnCollision = function(self, other) + if other == opponent then + applyDamage(damageToApply) + firing = false + -- destroy the tool + generatedImage:RemoveFromParent() + end + end + end + end +end + +function applyDamage(e) + local target = Player + if e.targetName ~= "player" then + target = opponent + end + + if target and hpBars[target.healthIndex + 1] and e.damageAmount then + local hpBarInfo = hpBars[target.healthIndex + 1] + -- Assuming damageAmount is the amount of HP to remove + local damageAmount = tonumber(e.damageAmount) + local currentPercentage = hpBarInfo.hpShape.LocalScale.Z / hpBarMaxLength + local currentHP = currentPercentage * playerMaxHP + -- Calculate new HP after taking damage + local newHP = math.max(currentHP - damageAmount, 0) + -- Update the target's HP visually + setHPBar(target, newHP, true) -- true to animate the HP bar change + -- Create a message to display the damage taken + Player:TextBubble(e.message) + -- if HP reaches 0, explode the target + if newHP <= 0 then + require("explode"):shapes(target) + -- Destroy the target + target:RemoveFromParent() + if target == opponent then + level = level + 1 + miniOpponents[randomOpponent[level]]:hide() + uiScore.Text = "Level: " .. level + doorAction(doors[level], "open") + + if level == NUMBER_OF_CORRIDORS then + opponent = nil + print("YOU WIN") + Player:TextBubble("Next Round") + endGame("YOU WIN") -- stop game........ if finish + else + opponent = spawnRandomOpponent() + end + elseif target == Player then + print("YOU LOSE") + Player:TextBubble("YOU Lose") + endGame("YOU Lose") -- stop game........ if finish + end + end + end +end + +-- Target +function showTarget(impact, pos) + if impact == nil then + return + end + + if _target == nil then + local ms = MutableShape() + ms:AddBlock(Color.White, 0, 0, 0) + + ms:AddBlock(Color.White, -2, 0, -2) + ms:AddBlock(Color.White, -2, 0, -1) + ms:AddBlock(Color.White, -1, 0, -2) + + ms:AddBlock(Color.White, -2, 0, 2) + ms:AddBlock(Color.White, -2, 0, 1) + ms:AddBlock(Color.White, -1, 0, 2) + + ms:AddBlock(Color.White, 2, 0, 2) + ms:AddBlock(Color.White, 2, 0, 1) + ms:AddBlock(Color.White, 1, 0, 2) + + ms:AddBlock(Color.White, 2, 0, -2) + ms:AddBlock(Color.White, 2, 0, -1) + ms:AddBlock(Color.White, 1, 0, -2) + + _target = Shape(ms) + _target.Pivot = { 0.5, 0.5, 0.5 } + _target.Physics = PhysicsMode.Disabled + end + + _target.LocalScale = Number3(0, 0, 0) + _target.LocalPosition = pos + _target.Up = faceNormals[impact.FaceTouched] or Number3(0, 1, 0) + _target.Tick = function(o, dt) + o:RotateLocal(o.Up, dt) + end + _target:SetParent(World) + ease:outElastic(_target, 0.4).LocalScale = { 1.6, 1, 1.6 } +end + +function deleteTarget() + if _target ~= nil then + _target:SetParent(nil) + end +end + +function showMenu(pointerEvent) + hideMenu() + + local impact = pointerEvent:CastRay(Map.CollisionGroups + { GENERATED_ITEM_COLLISION_GROUP }) + if impact ~= nil then + if impact.Object and impact.Object.isCraftButton then + impact.Object.LocalPosition.Z = impact.Object.LocalPosition.Z + 1 + displayCraftMenu(pointerEvent, impact, impact.Object.Position) + Timer(0.2, function() + impact.Object.LocalPosition.Z = impact.Object.LocalPosition.Z - 1 + impact.Object.pushed = false + end) + end + end +end + +function hideMenu() + if createButton then + createButton:remove() + createButton = nil + end + if prompt then + prompt:remove() + prompt = nil + end + if itemDetails then + itemDetails:remove() + itemDetails = nil + end + deleteTarget() +end + +Client.OnChat = function(message) + local e = Event() + e.action = "chat" + e.msg = message + e:SendTo(Players) +end + +-- Server code +Server.OnStart = function() + gens = {} + encounterEvents = {} + firing = false +end + +Server.OnPlayerJoin = function(p) + print("Player joined: " .. p.Username) + Timer(2, function() + for _, d in ipairs(gens) do + local headers = {} + headers["Content-Type"] = "application/octet-stream" + HTTP:Get(d.url, headers, function(data) + local e = Event() + e.vox = data.Body + e.id = d.e.id + e.pos = d.e.pos + e.rotY = d.e.rotY + e.userInput = d.e.userInput + e.user = d.e.user + e.action = "imageIsGenerated" + e:SendTo(p) + end) + end + end) +end + +Server.DidReceiveEvent = function(e) + if e.action == "requestImageGeneration" then + local eMy = Event() + eMy.opponentName = e.opponentName + eMy.userInput = e.userInput + resolveEncounter(eMy) + + -- Step 2: Continue with the image generation request + local headers = {} + local apiURL = "https://api.voxdream.art" + + headers["Content-Type"] = "application/json" + HTTP:Post(apiURL .. "/pixelart/vox", headers, { userInput = e.userInput }, function(data) + local body = JSON:Decode(data.Body) + if not body.urls or #body.urls == 0 then + print("Error: can't generate content.") + return + end + voxURL = apiURL .. "/" .. body.urls[1] + table.insert(gens, { e = e, url = voxURL }) + + local headers = {} + headers["Content-Type"] = "application/octet-stream" + HTTP:Get(voxURL, headers, function(data) + local e2 = Event() + e2.vox = data.Body + e2.user = e.Sender.Username + e2.userInput = e.userInput + e2.id = e.id + e2.action = "imageIsGenerated" + e2:SendTo(Players) + end) + end) + else + print("Unknown action: " .. e.action) + end +end + +function resolveEncounter(e) + -- Construct the URL for the encounter resolution + local apiURL = "https://gig.ax/api/encounter/" + apiURL = apiURL .. "?opponent=" .. e.opponentName .. "&tool=" .. e.userInput + + HTTP:Get(apiURL, {}, function(data) + local result = JSON:Decode(data.Body) + + if result and result.operations then + encounterEvents = processEncounterResult(result) + -- Send all events to all players + for _, event in ipairs(encounterEvents) do + event:SendTo(Players) + end + else + print("Error: can't resolve encounter or no operations returned.") + end + end) +end + +function processEncounterResult(result) + local events = {} + for _, operation in ipairs(result.operations) do + if operation.name == "HURT" then + local targetName = operation.parameters[1] + local damageAmount = tonumber(operation.parameters[2]) + + local hpUpdateEvent = Event() + hpUpdateEvent.action = "damageToApply" + hpUpdateEvent.targetName = targetName + hpUpdateEvent.damageAmount = damageAmount + hpUpdateEvent.message = result.description.text + table.insert(events, hpUpdateEvent) + elseif operation.name == "NOTHING" then + local updateEvent = Event() + updateEvent.action = "no_effect" + updateEvent.description = result.description.text + table.insert(events, updateEvent) + end + end + + return events +end + + +-- #### HP Bar stuff ##################### +-- create and attach HP bar to given player +attachHPBar = function(obj) + local frame = Shape(Items.petroglyph.hp_bar) + obj:AddChild(frame) + frame.LocalPosition = { 0, 35, 4.5 } + frame.LocalRotation = { 0, math.pi * .5, 0 } -- item was drawn along Z + if hpBarMaxLength == nil then + hpBarMaxLength = frame.Depth - 2 + end + + local hp = Shape(Items.petroglyph.block_red) + frame:AddChild(hp) + hp.Pivot.Z = 0 + hp.LocalPosition.Z = -hpBarMaxLength * .5 + hp.LocalScale.Z = hpBarMaxLength + hp.IsHidden = true + + local hpFull = Shape(Items.petroglyph.block_green) + frame:AddChild(hpFull) + hpFull.LocalScale.Z = hpBarMaxLength + + --print("Attaching HP bar to player at index: " .. obj.healthIndex .. " player name is " .. obj.name) + hpBar = { + player = obj, + startScale = hpBarMaxLength, + targetScale = hpBarMaxLength, + timer = 0, + hpShape = hp, + hpFullShape = hpFull, + frameShape = frame + } + --print("hpBar length: " .. hpBar.hpShape.LocalScale.Z) + hpBars[obj.healthIndex + 1] = hpBar +end + +-- remove HP bar from player +removeHPBar = function(player) + hpBars[player.healthIndex + 1] = {} +end + +-- sets target HP for given player, animated by default +setHPBar = function(player, hpValue, isAnimated) + local hp = hpBars[player.healthIndex + 1] + local v = clamp(hpValue / playerMaxHP, 0, 1) + if isAnimated or isAnimated == nil then + hp.startScale = hp.hpShape.LocalScale.Z + hp.targetScale = hpBarMaxLength * v + hp.timer = playerHPDuration + else + if v == 1.0 then + hp.hpFullShape.IsHidden = false + hp.hpShape.IsHidden = true + else + hp.hpFullShape.IsHidden = true + hp.hpShape.IsHidden = false + hp.hpShape.LocalScale.Z = hpBarMaxLength * v + end + hp.timer = 0 + end +end + +-- update all HP bars animation +updateHPBars = function(dt, i) + local hp = nil + for i = 1, #hpBars, 1 do + hp = hpBars[i] + if hp.timer ~= nil and hp.timer > 0 then + hp.timer = hp.timer - dt + + local delta = hp.targetScale - hp.startScale + local v = clamp(1 - hp.timer / playerHPDuration, 0, 1) + hp.hpShape.LocalScale.Z = hp.startScale + delta * v + + local isFull = hp.hpShape.LocalScale.Z == hpBarMaxLength + hp.hpFullShape.IsHidden = not isFull + hp.hpShape.IsHidden = isFull + + if delta < 0 then + hp.frameShape.LocalPosition.X = playerHPNegativeShake * math.sin(60 * hp.timer) + else + hp.frameShape.LocalPosition.Y = 26 + playerHPPositiveBump * math.sin(v * math.pi) + end + end + end +end + + +-- #### UI Stuff ##################### + +-- shows instructions at the top left corner of the screen +function showInstructions() + if instructions ~= nil then + instructions:show() + return + end + + instructions = ui:createFrame(Color(0, 0, 0, 128)) + local line1 = ui:createText("🎥 Drag to move camera", Color.White) + line1:setParent(instructions) + local line2 = ui:createText("☝️ Click on the CRAFT WEAPON button to attack the monster!", Color.White) + line2:setParent(instructions) + local line3 = ui:createText("🔎 Beat all the monsters to win the game.", Color.White) + line3:setParent(instructions) + + instructions.parentDidResize = function() + local width = math.max(line1.Width, line2.Width, line3.Width) + PADDING * 2 + local height = line1.Height + line2.Height + line3.Height + PADDING * 4 + instructions.Width = width + instructions.Height = height + line1.pos = { PADDING, instructions.Height - PADDING - line1.Height, 0 } + line2.pos = line1.pos - { 0, line1.Height + PADDING, 0 } + line3.pos = line2.pos - { 0, line2.Height + PADDING, 0 } + instructions.pos = { Screen.SafeArea.Left + PADDING, Screen.Height - Screen.SafeArea.Top - instructions.Height - PADDING, 0 } + end + instructions:parentDidResize() +end + +function hideInstructions() + if instructions ~= nil then + instructions:hide() + end +end + +function showWelcomeHint() + if welcomeHint ~= nil then + return + end + welcomeHint = ui:createText("Click on the CRAFT WEAPON block!", Color(1.0, 1.0, 1.0), "big") + welcomeHint.parentDidResize = function() + welcomeHint.pos.X = Screen.Width * 0.5 - welcomeHint.Width * 0.5 + welcomeHint.pos.Y = Screen.Height * 0.66 - welcomeHint.Height * 0.5 + end + welcomeHint:parentDidResize() + + local t = 0 + welcomeHint.object.Tick = function(o, dt) + t = t + dt + if (t % 0.4) <= 0.2 then + o.Color = Color(0.0, 0.8, 0.6) + else + o.Color = Color(1.0, 1.0, 1.0) + end + end +end + +function hideWelcomeHint() + if welcomeHint == nil then + return + end + welcomeHint:remove() + welcomeHint = nil +end + +-- creates loading bubble +function makeBubble(e) + local bubble = MutableShape() + bubble:AddBlock(Color.White, 0, 0, 0) + bubble:SetParent(World) + bubble.Pivot = Number3(0.5, 0, 0.5) + bubble.Position = e.pos + bubble.Rotation.Y = e.rotY + bubble.eid = e.id + + bubble.Tick = function(o, dt) + o.Scale.X = o.Scale.X + dt * 2 + o.Scale.Y = o.Scale.Y + dt * 2 + if o.text ~= nil then + o.text.Position = o.Position + o.text.Position.Y = o.Position.Y + o.Height * o.Scale.Y + 1 + end + end + + local t = Text() + t:SetParent(World) + t.Rotation.Y = e.rotY + t.Text = e.m + t.Type = TextType.World + t.IsUnlit = true + t.Tail = true + t.Anchor = { 0.5, 0 } + t.Position.Y = bubble.Position.Y + bubble.Height * bubble.Scale.Y + 1 + bubble.text = t + + -- remove after 15 seconds without response + Timer(15, function() + if bubble then + gens[bubble.eid] = nil + bubble.Tick = nil + if bubble.text then + bubble.text:RemoveFromParent() + end + bubble:RemoveFromParent() + end + end) + + gens[e.id] = bubble +end + +function splitAtFirst(inputString, delimiter) + local pos = string.find(inputString, delimiter, 1, true) + if pos then + return string.sub(inputString, 1, pos - 1), string.sub(inputString, pos + 1) + else + return inputString + end +end + +-- #### Utility functions ##################### + +clamp = function(value, min, max) + if value < min then + return min + elseif value > max then + return max + else + return value + end +end \ No newline at end of file