From 9d2a12415fcc191781ec44b6cd489bb776868916 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Fri, 27 Jun 2025 10:13:53 -0700 Subject: [PATCH 01/13] Subway surfers world --- nickb30/game.lua | 1189 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1189 insertions(+) create mode 100644 nickb30/game.lua diff --git a/nickb30/game.lua b/nickb30/game.lua new file mode 100644 index 0000000..d1589fe --- /dev/null +++ b/nickb30/game.lua @@ -0,0 +1,1189 @@ +Modules = { + sfx = "sfx", + controls = "controls", + ease = "ease", + ui = "uikit", + webquad = "github.com/aduermael/modzh/webquad:7fbc37d", +} + +Config.Items = { + --grass = "s12.grass_cubzh", +} + +--Dev.DisplayColliders = true + +-- CONSTANTS +local JUMP_STRENGTH = 100 +local SCORE_PER_SECOND = 100 +local ANIMATION_SPEED = 1.5 +local NORMAL_GAME_SPEED = 80 +local SLOW_DOWN_MULTIPLIER = 0.65 +local SLOW_DOWN_DURATION = 3.0 +local LANE_WIDTH = 30 +local BUILDING_FAR = 700 +local DIFFICULTY_INCREASE_RATE = 0.02 -- How fast difficulty increases per second +local MAX_DIFFICULTY_MULTIPLIER = 2.5 -- Maximum difficulty multiplier +local STATES = { + LOADING = 1, + MENU = 2, + READY = 3, -- New state: waiting for player input to start + RUNNING = 4, + GAME_OVER = 5, +} + +-- COLLISION GROUPS +local COLLISION_GROUPS = { + GROUND = CollisionGroups(1), + MOTION = CollisionGroups(2), -- for all objects in motion + COLLIDERS = CollisionGroups(3), + COLLECTIBLES = CollisionGroups(4), + PLAYER = CollisionGroups(5), +} + +-- SEGMENT SYSTEM +local SEGMENT_LENGTH = 120 -- How long each segment is (increased from 80) +local SEGMENTS_AHEAD = 3 -- How many segments to keep ahead of player +local MAX_Z_POSITION = 500 -- Maximum Z position before resetting (increased from 400) + +-- Lane-based obstacle spawning system +local laneTrackers = { + left = { lastSpawnZ = 0, minDistance = 100, wallTrainCount = 0, stairsSpawned = false }, -- Left lane (-1) + center = { lastSpawnZ = 0, minDistance = 100, wallTrainCount = 0, stairsSpawned = false }, -- Center lane (0) + right = { lastSpawnZ = 0, minDistance = 100, wallTrainCount = 0, stairsSpawned = false } -- Right lane (1) +} + +-- Obstacle spawning probabilities and types +local obstacleTypes = { + { type = "log", probability = 0.4, minDistance = 80 }, + { type = "wall", probability = 0.25, minDistance = 120, trainLength = {1, 5} }, + { type = "flag", probability = 0.2, minDistance = 100 }, + --{ type = "stairs", probability = 0.15, minDistance = 120 } +} + +-- Obstacle combinations for multi-lane patterns +local combinationPatterns = { + { lanes = {-1, 1}, probability = 0.3, minDistance = 100 }, -- Side lanes + { lanes = {0}, probability = 0.4, minDistance = 80 }, -- Center lane + { lanes = {-1, 0, 1}, probability = 0.1, minDistance = 150 }, -- All lanes + { lanes = {-1, 0}, probability = 0.1, minDistance = 90 }, -- Left + center + { lanes = {0, 1}, probability = 0.1, minDistance = 90 } -- Center + right +} + +-- obstacle parts +local wallPart +local flagPart +local logPart +local stairsPart + +-- Simple segment manager +local segments = {} -- Active segments +local nextSegmentZ = 100 -- Z position for next segment (increased from 50) + +-- GAME STATE VARIABLES +local downPos +local isMoving = false +local targetLane = 0 +local targetPosition = nil +local swipeTriggered = false +local currentLane = 0 +local obstaclesByRef = {} +local gameSpeed = 80 +local isGameOver = false +local lanePositions = {Number3(-30, 0, 0), Number3(0, 0, 0), Number3(30, 0, 0)} -- left, center, right +local isSlowDownActive = false +local slowDownTimer = 0 +local score = 0 +local gameProgress = 0 -- Track game progress for spawning +local difficultyMultiplier = 1.0 -- Current difficulty multiplier +local gameTime = 0 -- Total time the game has been running +local isCrouching = false +local crouchTimer = 0 +local CROUCH_DURATION = 1.0 -- How long to stay crouched +local NORMAL_SCALE = 0.5 -- The player's normal scale +local CROUCH_SCALE = 0.25 -- How much to scale down when crouching (50% of normal size) +local wantsToCrouch = false -- Track if player wants to crouch while in air +local scoreText = nil +local scoreValueText = nil +local highScoreText = nil +local highScoreValueText = nil +local newHighScoreText = nil +local newHighScorePanel = nil +local coordinatesText = nil +local targetLaneText = nil +local restartText = nil +local currentState = STATES.LOADING +local assetsLoaded = 0 +local totalAssets = 4 -- log, wall, flag, stairs + +-- UI STYLING CONSTANTS +local UI_COLORS = { + primary = Color(255, 255, 255), -- White + secondary = Color(200, 200, 200), -- Light gray + accent = Color(255, 215, 0), -- Gold + background = Color(0, 0, 0, 0.8), -- Semi-transparent black + border = Color(255, 255, 255, 0.4), -- Semi-transparent white + shadow = Color(0, 0, 0, 0.3) -- Shadow color +} + +local UI_POSITIONS = { + scorePanel = {x = 20, y = 20}, + highScorePanel = {x = 20, y = 80} +} + +-- UI Helper Functions +local function createStyledText(text, fontSize, color, isBold) + local textObj = ui:createText(text) + textObj.FontSize = fontSize or 16 + textObj.Color = color or UI_COLORS.primary + if isBold then + textObj.Font = "Bold" + end + return textObj +end + +local function createScorePanel() + -- Score Panel Background + local scorePanel = ui:createFrame() + scorePanel.Size = {200, 65} + scorePanel.Color = UI_COLORS.background + scorePanel.BorderRadius = 12 + scorePanel.BorderColor = UI_COLORS.border + scorePanel.BorderWidth = 2 + + -- Score Label + scoreText = createStyledText("SCORE", 12, UI_COLORS.secondary, true) + scoreText.parentDidResize = function() + scoreText.pos = {UI_POSITIONS.scorePanel.x + 15, UI_POSITIONS.scorePanel.y + 40} + end + + -- Score Value + scoreValueText = createStyledText("0", 24, UI_COLORS.primary, true) + scoreValueText.parentDidResize = function() + scoreValueText.pos = {UI_POSITIONS.scorePanel.x + 15, UI_POSITIONS.scorePanel.y + 10} + end + + -- Position panel background + scorePanel.parentDidResize = function() + scorePanel.pos = {UI_POSITIONS.scorePanel.x, UI_POSITIONS.scorePanel.y} + end + + scorePanel:parentDidResize() + scoreText:parentDidResize() + scoreValueText:parentDidResize() +end + +local function createHighScorePanel() + -- High Score Panel Background + local highScorePanel = ui:createFrame() + highScorePanel.Size = {200, 65} + highScorePanel.Color = UI_COLORS.background + highScorePanel.BorderRadius = 12 + highScorePanel.BorderColor = UI_COLORS.border + highScorePanel.BorderWidth = 2 + + -- High Score Label + highScoreText = createStyledText("BEST", 12, UI_COLORS.secondary, true) + highScoreText.parentDidResize = function() + highScoreText.pos = {UI_POSITIONS.highScorePanel.x + 15, UI_POSITIONS.highScorePanel.y + 40} + end + + -- High Score Value + highScoreValueText = createStyledText("0", 24, UI_COLORS.accent, true) + highScoreValueText.parentDidResize = function() + highScoreValueText.pos = {UI_POSITIONS.highScorePanel.x + 15, UI_POSITIONS.highScorePanel.y + 10} + end + + -- Position panel background + highScorePanel.parentDidResize = function() + highScorePanel.pos = {UI_POSITIONS.highScorePanel.x, UI_POSITIONS.highScorePanel.y} + end + + highScorePanel:parentDidResize() + highScoreText:parentDidResize() + highScoreValueText:parentDidResize() +end + +local function createRestartText() + restartText = createStyledText("", 20, UI_COLORS.primary, true) + restartText.parentDidResize = function() + restartText.pos = { Screen.Width / 2 - restartText.Width / 2, Screen.Height / 2 - restartText.Height / 2} + end + restartText:parentDidResize() +end + +local function createNewHighScoreText() + -- New High Score Background Panel + newHighScorePanel = ui:createFrame() + newHighScorePanel.Size = {400, 60} + newHighScorePanel.Color = Color(0, 0, 0, 0) -- Start transparent + newHighScorePanel.BorderRadius = 12 + newHighScorePanel.BorderColor = UI_COLORS.accent + newHighScorePanel.BorderWidth = 3 + + newHighScoreText = createStyledText("", 32, UI_COLORS.accent, true) + newHighScoreText.parentDidResize = function() + newHighScoreText.pos = { Screen.Width / 2 - newHighScoreText.Width / 2, Screen.Height * 0.666 - newHighScoreText.Height / 2} + end + + -- Position background panel + newHighScorePanel.parentDidResize = function() + newHighScorePanel.pos = { Screen.Width / 2 - newHighScorePanel.Size.Width / 2, Screen.Height * 0.666 - newHighScorePanel.Size.Height / 2} + end + + newHighScorePanel:parentDidResize() + newHighScoreText:parentDidResize() + newHighScoreText.Text = "" -- Start hidden +end + +local function updateScoreDisplay(newScore) + if scoreValueText then + scoreValueText.Text = string.format("%.0f", newScore) + end +end + +local function updateHighScoreDisplay(newHighScore) + if highScoreValueText then + highScoreValueText.Text = string.format("%.0f", newHighScore) + end +end + +function startCrouch() + if not isCrouching then + if Player.IsOnGround then + -- Player is on ground, crouch immediately + isCrouching = true + crouchTimer = CROUCH_DURATION + Player.Scale.Y = CROUCH_SCALE -- Shrink to 0.25 + else + -- Player is in air, mark that they want to crouch when they land + wantsToCrouch = true + end + end +end + +function cancelCrouch() + if isCrouching then + isCrouching = false + crouchTimer = 0 + Player.Scale.Y = NORMAL_SCALE -- Return to normal size + end + wantsToCrouch = false -- Also cancel any pending air crouch +end + +function updateCrouch(dt) + -- Check if player wanted to crouch and just landed + if wantsToCrouch and Player.IsOnGround then + wantsToCrouch = false + isCrouching = true + crouchTimer = CROUCH_DURATION + Player.Scale.Y = CROUCH_SCALE -- Apply crouch scale now that they're on ground + end + + if isCrouching then + crouchTimer = crouchTimer - dt + if crouchTimer <= 0 then + isCrouching = false + Player.Scale.Y = NORMAL_SCALE -- Return to normal size + end + end +end + +if Client.IsMobile then + Client.DirectionalPad = nil + Client.Action1 = nil +else + Client.DirectionalPad = function(x, y) + if currentState == STATES.GAME_OVER then + restartGame() + return + end + + if currentState == STATES.MENU then + -- Transition from MENU to READY + currentState = STATES.READY + if restartText then + restartText.Text = "Press W or swipe to start" + restartText.parentDidResize() + end + return + end + + if currentState == STATES.READY then + startGame() + return + end + + if x == 1 then + targetLane += 1 + isMoving = true + elseif x == -1 then + targetLane -= 1 + isMoving = true + end + if y == 1 then + if Player.IsOnGround then + cancelCrouch() -- Cancel crouch when jumping + Player.Velocity.Y = JUMP_STRENGTH + end + elseif y == -1 then + if not Player.IsOnGround then + Player.Velocity.Y = -JUMP_STRENGTH -- Fall faster + startCrouch() -- Mark that player wants to crouch when landing + else + startCrouch() + end + end + end +end + +Pointer.Down = function(pe) + downPos = Number2(pe.X, pe.Y) * Screen.Size +end + +Pointer.Up = function(pe) + swipeTriggered = false +end + +Pointer.Cancel = function(pe) + swipeTriggered = false +end + +function updateScore(dt) + score = score + (SCORE_PER_SECOND * dt) +end + +-- Called when Pointer is "shown" (Pointer.IsHidden == false), which is the case by default. +Pointer.Drag = function(pe) + print("Pointer.Drag called, currentState: " .. currentState) + if currentState == STATES.GAME_OVER then + restartGame() + return + end + + if currentState == STATES.MENU then + -- Transition from MENU to READY + currentState = STATES.READY + if restartText then + restartText.Text = "Swipe or jump to start" + restartText.parentDidResize() + end + return + end + + if currentState == STATES.READY then + startGame() + return + end + + local pos = Number2(pe.X, pe.Y) * Screen.Size + local Xdiff = pos.X - downPos.X + local Ydiff = pos.Y - downPos.Y + + if swipeTriggered == false then + -- Swipe Right + if Xdiff > 50 and currentLane <= 0 then + swipeTriggered = true + print("Swipe right") + targetLane += 1 + isMoving = true + elseif Xdiff < -50 and currentLane >= 0 then + swipeTriggered = true + print("Swipe left") + targetLane -= 1 + isMoving = true + elseif Ydiff > 50 then + swipeTriggered = true + if Player.IsOnGround then + cancelCrouch() -- Cancel crouch when jumping + Player.Velocity.Y = JUMP_STRENGTH + end + elseif Ydiff < -50 then + swipeTriggered = true + if not Player.IsOnGround then + Player.Velocity.Y = -JUMP_STRENGTH -- Fall faster + startCrouch() -- Mark that player wants to crouch when landing + else + startCrouch() + end + end + end + end + +Client.OnWorldObjectLoad = function(o) + if o.Name == "ground" then + o.IsHidden = true + -- print("ground height: " .. o.Position.Y) + -- print(o.Height) + -- print("pivot: " .. o.Pivot.Y) + groundLevel = o.Position.Y + o.Height * o.Scale.Y + o.CollisionGroups = COLLISION_GROUPS.GROUND + o.CollidesWithGroups = COLLISION_GROUPS.PLAYER + end +end + +-- function executed when the game starts +Client.OnStart = function() + Player.CollisionGroups = COLLISION_GROUPS.PLAYER + Player.CollidesWithGroups = COLLISION_GROUPS.GROUND + COLLISION_GROUPS.COLLIDERS + + -- skybox + HTTP:Get("https://files.blip.game/skyboxes/sunny-sky-with-clouds.ktx", function(response) + if response.StatusCode == 200 then + Sky.Image = response.Body + Sky.SkyColor = Color.White + Sky.HorizonColor = Color.White + Sky.AbyssColor = Color.White + end + end) + -- Collision Groups + -- Leaderboard + leaderboard = Leaderboard("default") + + -- ground texture + groundImage = webquad:create({ + color = Color.White, + url = "https://files.cu.bzh/textures/asphalt.png", + }) + local tiling = BUILDING_FAR / 32 + groundImage.Width = BUILDING_FAR * 2 + groundImage.Height = BUILDING_FAR * 2 + groundImage.Tiling = { tiling, tiling } + groundImage.Anchor = { 0.5, 0.5 } + groundImage.IsDoubleSided = false + groundImage.Position.Y = groundLevel + World:AddChild(groundImage) + groundImage.Rotation = { math.pi * 0.5, 0, 0 } + + -- yellow lines (placeoholder for lanes) + yellowLineLeft = webquad:create({ + color = Color.White, + url = "https://files.cu.bzh/textures/asphalt-yellow-lines.png", + }) + yellowLineLeft.Width = 3 + yellowLineLeft.Height = BUILDING_FAR * 2 + yellowLineLeft.Tiling = { 1, tiling } + yellowLineLeft.Anchor = { 0.5, 0.5 } + yellowLineLeft.IsDoubleSided = false + yellowLineLeft.Position = groundImage.Position + { -LANE_WIDTH, 0.1, 0 } + World:AddChild(yellowLineLeft) + yellowLineLeft.Rotation = { math.pi * 0.5, 0, 0 } + + yellowLineMiddle = webquad:create({ + color = Color.White, + url = "https://files.cu.bzh/textures/asphalt-yellow-lines.png", + }) + yellowLineMiddle.Width = 3 + yellowLineMiddle.Height = BUILDING_FAR * 2 + yellowLineMiddle.Tiling = { 1, tiling } + yellowLineMiddle.Anchor = { 0.5, 0.5 } + yellowLineMiddle.IsDoubleSided = false + yellowLineMiddle.Position = groundImage.Position + { 0, 0.1, 0 } + World:AddChild(yellowLineMiddle) + yellowLineMiddle.Rotation = { math.pi * 0.5, 0, 0 } + + yellowLineRight = webquad:create({ + color = Color.White, + url = "https://files.cu.bzh/textures/asphalt-yellow-lines.png", + }) + yellowLineRight.Width = 3 + yellowLineRight.Height = BUILDING_FAR * 2 + yellowLineRight.Tiling = { 1, tiling } + yellowLineRight.Anchor = { 0.5, 0.5 } + yellowLineRight.IsDoubleSided = false + yellowLineRight.Position = groundImage.Position + { LANE_WIDTH, 0.1, 0 } + World:AddChild(yellowLineRight) + yellowLineRight.Rotation = { math.pi * 0.5, 0, 0 } + + local function wrapMesh(mesh, scale, type) + local wrapper = Object() + wrapper:AddChild(mesh) + wrapper.Physics = PhysicsMode.Dynamic + mesh.Physics = PhysicsMode.Disabled + mesh.Scale = scale + + if type == "log" then + -- set scale and rotation + local fixedRotation = Number3(0, math.rad(90), 0) + scale:Rotate(fixedRotation) + mesh.LocalRotation = fixedRotation + mesh.Scale = scale + local box = Box() + box:Fit(wrapper, { recurse = true, localBox = true}) + wrapper.CollisionBox = box + wrapper.CollisionGroups = COLLISION_GROUPS.COLLIDERS + COLLISION_GROUPS.MOTION + wrapper.CollidesWithGroups = COLLISION_GROUPS.PLAYER + elseif type == "wall" then + -- set scale and rotation + local fixedRotation = Number3(math.rad(90), 0, 0) + scale:Rotate(fixedRotation) + mesh.LocalRotation = fixedRotation + mesh.Scale = scale + + -- set collision box and groups + local box = Box() + box:Fit(wrapper, { recurse = true, localBox = true}) + box.Max -= Number3(0, 4, 0) + box.Max += Number3(5, 0, 0) + wrapper.CollisionBox = box + wrapper.CollisionGroups = COLLISION_GROUPS.COLLIDERS + COLLISION_GROUPS.MOTION + wrapper.CollidesWithGroups = COLLISION_GROUPS.PLAYER + elseif type == "flag" then + -- set scale and rotation + local fixedRotation = Number3(0, math.rad(90), 0) + scale:Rotate(fixedRotation) + mesh.LocalRotation = fixedRotation + mesh.Scale = scale + + -- set collision box and groups + local box = Box() + box:Fit(wrapper, { recurse = true, localBox = true}) + box.Min += Number3(0, 10, 0) + wrapper.CollisionBox = box + wrapper.CollisionGroups = COLLISION_GROUPS.MOTION + wrapper.CollidesWithGroups = COLLISION_GROUPS.PLAYER + + elseif type == "stairs" then + -- set scale and rotation + local fixedRotation = Number3(0, 0, 0) + scale:Rotate(fixedRotation) + mesh.LocalRotation = fixedRotation + mesh.Scale = scale + + -- set collision box and groups - make it a trigger for boost + local box = Box() + box:Fit(wrapper, { recurse = true, localBox = true}) + wrapper.CollisionBox = box + wrapper.CollisionGroups = COLLISION_GROUPS.MOTION + wrapper.CollidesWithGroups = nil + + -- Create a trigger for player interaction + local trigger = Object() + trigger.Physics = PhysicsMode.Trigger + local triggerBox = Box() + triggerBox:Fit(wrapper, { recurse = true, localBox = true}) + triggerBox.Min.Y = triggerBox.Min.Y + 5 -- Start trigger a bit above ground + trigger.CollisionBox = triggerBox + wrapper:AddChild(trigger) + trigger.CollisionGroups = nil + trigger.CollidesWithGroups = COLLISION_GROUPS.PLAYER + end + return wrapper + end + + -- load log asset + HTTP:Get("https://files.blip.game/gltf/kenney/tree-log.glb", function(response) + if response.StatusCode == 200 then + local req = Object:Load(response.Body, function(o) + logPart = wrapMesh(o, Number3(30, 40, 40), "log") + print("Log part loaded.") + assetsLoaded = assetsLoaded + 1 + if assetsLoaded == totalAssets then + currentState = STATES.MENU + end + end) + end + end) + -- load wall asset + HTTP:Get("https://files.blip.game/gltf/kenney/castle-wall-4.glb", function(response) + if response.StatusCode == 200 then + local req = Object:Load(response.Body, function(o) + wallPart = wrapMesh(o, Number3(20, 30, 50), "wall") + print("Wall part loaded.") + assetsLoaded = assetsLoaded + 1 + if assetsLoaded == totalAssets then + currentState = STATES.MENU + end + end) + end + end) + + -- load flag asset + HTTP:Get("https://files.blip.game/gltf/kenney/flag-wide.glb", function(response) + if response.StatusCode == 200 then + local req = Object:Load(response.Body, function(o) + flagPart = wrapMesh(o, Number3(35, 35, 30), "flag") + print("Flag part loaded.") + assetsLoaded = assetsLoaded + 1 + if assetsLoaded == totalAssets then + currentState = STATES.MENU + end + end) + end + end) + + -- load stairs assest + HTTP:Get("https://files.blip.game/gltf/kenney/stairs.glb", function(response) + if response.StatusCode == 200 then + local req = Object:Load(response.Body, function(o) + stairsPart = wrapMesh(o, Number3(100, 60, 30), "stairs") + print("Stairs part loaded.") + assetsLoaded = assetsLoaded + 1 + if assetsLoaded == totalAssets then + currentState = STATES.MENU + end + end) + else + print("Failed to load stairs asset, status: " .. response.StatusCode) + end + end) + + -- Create modern UI panels + createScorePanel() + createHighScorePanel() + createRestartText() + createNewHighScoreText() + + -- Load high score with callback + function loadHighScore() + leaderboard:get({ + mode = "best", + friends = true, + limit = 10, + callback = function(scores, err) + if err == nil and scores then + local playerHighScore = 0 + -- Loop through scores to find the current player's score + for _, scoreData in ipairs(scores) do + if scoreData.userID == Player.UserID then + playerHighScore = scoreData.score or 0 + break + end + end + updateHighScoreDisplay(playerHighScore) + -- print("Player's high score: " .. playerHighScore) + else + updateHighScoreDisplay(0) + -- print("No high score data available") + end + end + }) + end + + -- Load the high score initially + loadHighScore() + + Player.Animations.Walk.Speed = ANIMATION_SPEED + Player.Animations.Walk:Play() + -- print("Initial Player.Scale.Y:", Player.Scale.Y) + Player.Scale.Y = NORMAL_SCALE -- Ensure player starts at normal scale + World:AddChild(Player) + dropPlayer() + + Camera.Behavior = { + positionTarget = Player, -- camera goes to that position (or position of given object) + positionTargetOffset = { 0, 25, 0 }, -- applying offset to the target position (increased Y offset) + positionTargetBackoffDistance = 60, -- camera then tries to backoff that distance, considering collision (increased from 40) + positionTargetMinBackoffDistance = 30, -- minimum backoff distance (increased from 20) + positionTargetMaxBackoffDistance = 120, -- maximum backoff distance (increased from 100) + rotationTarget = Player.Head, -- camera rotates to that rotation (or rotation of given object) + rigidity = 0.3, -- how fast the camera moves to the target (reduced for smoother movement) + collidesWithGroups = nil, -- camera will not go through objects in these groups + } + + Player.OnCollisionBegin = function(self, other, normal) + -- ignore collisions with the ground + + if other.Physics == PhysicsMode.Trigger or other.Physics == PhysicsMode.Static then + --print("other rotation: " .. other.Rotation.X .. ", " .. other.Rotation.Y .. ", " .. other.Rotation.Z) + if other.Parent ~= nil then + -- Check if this is a stairs trigger + local parent = other.Parent + if obstaclesByRef[parent] == "stairs" then + --print("Stairs boost triggered!") + -- Give the player a boost up and forward + Player.Motion.Y = gameSpeed * 1.5 -- Upward boost + return + end + other = parent + print("other rotation: " .. other.Rotation.X .. ", " .. other.Rotation.Y .. ", " .. other.Rotation.Z) + end + end + + if not obstaclesByRef[other] then + return + end + + local obstacleType = obstaclesByRef[other] + --print("Collision with " .. obstacleType) + -- print normal vector + --print("Normal: " .. normal.X .. ", " .. normal.Y .. ", " .. normal.Z) + + -- For all obstacles, use the original logic + if isSlowDownActive and normal.Y == 0 or normal.Z < 0 then + gameOver() + return + end + -- hit block from the right + if normal.Y == 0 then + if normal.X < 0 then + targetLane -= 1 + -- hit block from the left + elseif normal.X > 0 then + targetLane += 1 + end + isSlowDownActive = true + slowDownTimer = SLOW_DOWN_DURATION + end + end + + Player.OnCollisionEnd = function(self, other, normal) + if other.Physics == PhysicsMode.Trigger or other.Physics == PhysicsMode.Static then + --print("other rotation: " .. other.Rotation.X .. ", " .. other.Rotation.Y .. ", " .. other.Rotation.Z) + if other.Parent ~= nil then + -- Check if this is a stairs trigger + local parent = other.Parent + if obstaclesByRef[parent] == "stairs" then + --print("Stairs boost ended!") + Player.Motion.Y = 0 + return + end + other = parent + end + end + end +end + +function dropPlayer() + Player.Position:Set(0, 40, 0) + Player.Rotation:Set(0, 0, 0) + Player.Velocity:Set(0, 0, 0) + + -- Clear segments using the new system + clearSegments() + + -- Reset game state + targetLane = 0 + currentLane = 0 + isGameOver = false + isSlowDownActive = false + slowDownTimer = 0 + gameSpeed = NORMAL_GAME_SPEED + gameProgress = 0 -- Reset game progress + difficultyMultiplier = 1.0 -- Reset difficulty + gameTime = 0 -- Reset game time + isCrouching = false -- Reset crouch state + crouchTimer = 0 + wantsToCrouch = false -- Reset air crouch state + Player.Scale.Y = NORMAL_SCALE -- Reset player scale + Player.Animations.Walk.Speed = ANIMATION_SPEED + Player.Animations.Walk:Stop() + Player.Motion.Y = 0 + score = 0 + currentState = STATES.READY -- Start in READY state instead of RUNNING + + -- Update UI displays + updateScoreDisplay(score) + + -- Hide new high score text + if newHighScoreText then + newHighScoreText.Text = "" + end + if newHighScorePanel then + newHighScorePanel.Color = Color(0, 0, 0, 0) -- Make transparent + end + + -- Hide restart text and show start instruction + if restartText then + restartText.Text = "Press W or swipe to start" + restartText.parentDidResize() + end +end + +function gameOver() + leaderboard:set({score = score, callback = function() + loadHighScore() + end}) + isGameOver = true + print("Game Over") + currentState = STATES.GAME_OVER + Player.Animations.Walk:Stop() + clearSegments() + + -- Check if this is a new high score + local currentHighScore = tonumber(highScoreValueText.Text) or 0 + if score > currentHighScore then + if newHighScoreText and newHighScorePanel then + newHighScoreText.Text = "NEW HIGH SCORE: " .. string.format("%.0f", score) + newHighScoreText.parentDidResize() + newHighScorePanel.Color = UI_COLORS.background + end + end + + -- Show restart instruction + if restartText then + restartText.Text = "Tap to restart" + restartText.parentDidResize() + end +end + +function restartGame() + print("Restarting game...") + currentState = STATES.RUNNING + isGameOver = false + + -- Hide restart instruction + if restartText then + restartText.Text = "" + end + + -- Call dropPlayer to reset everything + dropPlayer() +end + +function startGame() + print("Starting game...") + currentState = STATES.RUNNING + + -- Start player animation + Player.Animations.Walk:Play() + + -- Start motion on all existing obstacles + for _, segment in ipairs(segments) do + for _, obstacle in ipairs(segment.obstacles) do + obstacle.Motion.Z = -gameSpeed + end + end + + -- Hide start instruction + if restartText then + restartText.Text = "" + else + end +end + +Client.Tick = function(dt) + if currentState == STATES.LOADING then + return + end + + if currentState == STATES.MENU then + -- In menu state, just spawn initial segments + updateSegments(gameProgress) + return + end + + if currentState == STATES.READY then + -- Update UI in ready state + updateScoreDisplay(score) + + -- Spawn segments but don't update score or move obstacles + updateSegments(gameProgress) + return + end + + if isGameOver then return end + + -- Update game progress based on time and game speed + gameProgress = gameProgress + (gameSpeed * dt) + + -- Update difficulty over time (only when game is running) + if currentState == STATES.RUNNING then + gameTime = gameTime + dt + difficultyMultiplier = math.min(MAX_DIFFICULTY_MULTIPLIER, 1.0 + (DIFFICULTY_INCREASE_RATE * gameTime)) + gameSpeed = NORMAL_GAME_SPEED * difficultyMultiplier + end + + updateScore(dt) + updateScoreDisplay(score) + updateCrouch(dt) -- Update crouch timer + --coordinatesText.Text = string.format("Coordinates: (%.1f, %.1f, %.1f)", Player.Position.X, Player.Position.Y, Player.Position.Z) + --targetLaneText.Text = "Target Lane: " .. targetLane + + if isSlowDownActive then + slowDownTimer -= dt + Player.Animations.Walk.Speed = ANIMATION_SPEED * SLOW_DOWN_MULTIPLIER + gameSpeed = NORMAL_GAME_SPEED * SLOW_DOWN_MULTIPLIER + updateObstacleSpeed(gameSpeed) + if slowDownTimer <= 0 then + isSlowDownActive = false + gameSpeed = NORMAL_GAME_SPEED * difficultyMultiplier -- Use current difficulty multiplier + Player.Animations.Walk.Speed = ANIMATION_SPEED + updateObstacleSpeed(gameSpeed) + end + end + + updateSegments(gameProgress) + groundImage.Offset.Y = groundImage.Offset.Y - dt * gameSpeed * 0.015 + yellowLineLeft.Offset.Y = yellowLineLeft.Offset.Y - dt * gameSpeed * 0.015 + yellowLineMiddle.Offset.Y = yellowLineMiddle.Offset.Y - dt * gameSpeed * 0.015 + yellowLineRight.Offset.Y = yellowLineRight.Offset.Y - dt * gameSpeed * 0.015 + + + if isMoving then + targetLane = math.max(-1, math.min(1, targetLane)) + targetPosition = lanePositions[targetLane + 2] + Player.Velocity.X = (targetPosition.X - Player.Position.X) * 1000 * dt + if math.abs(targetPosition.X - Player.Position.X) < 0.01 then + currentLane = targetLane + isMoving = false + end + end +end + +function spawnObstaclesAtPosition(zPosition) + local spawnedObstacles = {} + + -- Check each lane for spawning + for lane = -1, 1 do + local tracker = getLaneTracker(lane) + if tracker and canSpawnInLane(lane, zPosition) then + local obstacleData = selectObstacleType() + + -- If it's a wall, start a wall train + if obstacleData.type == "wall" and obstacleData.trainLength then + local trainLength = math.random(obstacleData.trainLength[1], obstacleData.trainLength[2]) + tracker.wallTrainCount = trainLength + tracker.stairsSpawned = false + + -- 50% chance to spawn stairs at the start of the wall train + if math.random() <= 0.5 then + local stairsObstacle = spawnObstacle("stairs", lane, zPosition) + if stairsObstacle then + table.insert(spawnedObstacles, stairsObstacle) + tracker.stairsSpawned = true + end + end + + -- Spawn all walls in the train at once with Z offsets + local wallSpacing = 50 -- Distance between walls + for i = 1, trainLength do + local wallZ = zPosition + (i * wallSpacing) + local wallObstacle = spawnObstacle("wall", lane, wallZ) + if wallObstacle then + table.insert(spawnedObstacles, wallObstacle) + end + end + + -- Update the lane tracker - mark that this wall train is complete + tracker.lastSpawnZ = zPosition + ((trainLength - 1) * wallSpacing) + tracker.minDistance = obstacleData.minDistance + tracker.wallTrainCount = 0 -- Reset wall train count after spawning + else + -- For non-wall obstacles, spawn normally + local obstacle = spawnObstacle(obstacleData.type, lane, zPosition) + if obstacle then + table.insert(spawnedObstacles, obstacle) + tracker.lastSpawnZ = zPosition + tracker.minDistance = obstacleData.minDistance + end + end + end + end + return spawnedObstacles +end + +function spawnObstacle(obstacleType, lane, zPosition) + local obstacle + + -- Check if required assets are loaded + if obstacleType == "log" and logPart == nil then + return nil + elseif obstacleType == "wall" and wallPart == nil then + return nil + elseif obstacleType == "flag" and flagPart == nil then + return nil + elseif obstacleType == "stairs" and stairsPart == nil then + return nil + end + + if obstacleType == "log" then + obstacle = logPart:Copy({ includeChildren = true }) + obstacle.Position = Number3(lane * LANE_WIDTH, groundLevel + 0.1, zPosition) + elseif obstacleType == "wall" then + obstacle = wallPart:Copy({ includeChildren = true }) + obstacle.Position = Number3(lane * LANE_WIDTH, groundLevel + 0.1, zPosition) + elseif obstacleType == "flag" then + obstacle = flagPart:Copy({ includeChildren = true }) + obstacle.Position = Number3(lane * LANE_WIDTH, groundLevel + 0.1, zPosition) + elseif obstacleType == "stairs" then + obstacle = stairsPart:Copy({ includeChildren = true }) + obstacle.Position = Number3(lane * LANE_WIDTH, groundLevel + 0.1, zPosition) + end + + if obstacle then + obstacle.Mass = 1000 + if currentState == STATES.RUNNING then + obstacle.Motion.Z = -gameSpeed + else + obstacle.Motion.Z = 0 + end + obstacle.Friction = 0 + obstacle.Acceleration = -Config.ConstantAcceleration + obstacle.Velocity = Number3(0, 0, 0) + + World:AddChild(obstacle) + obstaclesByRef[obstacle] = obstacleType + + return obstacle + end + + return nil +end + +function getLaneTracker(lane) + if lane == -1 then + return laneTrackers.left + elseif lane == 0 then + return laneTrackers.center + elseif lane == 1 then + return laneTrackers.right + end + return nil +end + +function canSpawnInLane(lane, currentZ) + local tracker = getLaneTracker(lane) + if not tracker then return false end + + -- If we're in a wall train, continue spawning walls + if tracker.wallTrainCount > 0 then + return true + end + + -- For non-wall train spawning, check minimum distance + return (currentZ - tracker.lastSpawnZ) >= tracker.minDistance +end + +function selectObstacleType() + local rand = math.random() + local cumulative = 0 + + -- Use all available obstacles including stairs + local totalProb = 0 + for _, obstacle in ipairs(obstacleTypes) do + totalProb = totalProb + obstacle.probability + end + + -- Select from all obstacles + rand = rand * totalProb + for _, obstacle in ipairs(obstacleTypes) do + cumulative = cumulative + obstacle.probability + if rand <= cumulative then + return obstacle + end + end + + -- Fallback to log if something goes wrong + return obstacleTypes[1] +end + +function selectCombinationPattern() + local rand = math.random() + local cumulative = 0 + + for _, pattern in ipairs(combinationPatterns) do + cumulative = cumulative + pattern.probability + if rand <= cumulative then + return pattern + end + end + + -- Fallback to center lane if something goes wrong + return combinationPatterns[2] +end + +function updateObstacleSpeed(newSpeed) + for _, segment in ipairs(segments) do + for _, obstacle in ipairs(segment.obstacles) do + obstacle.Motion.Z = -newSpeed + end + end +end + +function clearSegments() + for _, segment in ipairs(segments) do + for _, obstacle in ipairs(segment.obstacles) do + World:RemoveChild(obstacle) + obstaclesByRef[obstacle] = nil + end + end + segments = {} + nextSegmentZ = 100 -- Reset to starting position + + -- Reset lane trackers + laneTrackers.left.lastSpawnZ = 0 + laneTrackers.center.lastSpawnZ = 0 + laneTrackers.right.lastSpawnZ = 0 + laneTrackers.left.minDistance = 100 + laneTrackers.center.minDistance = 100 + laneTrackers.right.minDistance = 100 + laneTrackers.left.wallTrainCount = 0 + laneTrackers.center.wallTrainCount = 0 + laneTrackers.right.wallTrainCount = 0 + laneTrackers.left.stairsSpawned = false + laneTrackers.center.stairsSpawned = false + laneTrackers.right.stairsSpawned = false +end + +function updateSegments(gameProgress) + -- Don't spawn obstacles if assets aren't loaded yet + if logPart == nil or wallPart == nil or flagPart == nil or stairsPart == nil then + return + end + + -- Always keep obstacles spawning ahead of the current progress + local spawnDistance = 200 -- Distance ahead of current progress to spawn obstacles + local maxSpawnDistance = 400 -- Maximum distance to spawn obstacles ahead + + -- Reset lane trackers if lastSpawnZ is too far behind the current progress + for _, tracker in pairs(laneTrackers) do + if gameProgress - tracker.lastSpawnZ > maxSpawnDistance then + tracker.lastSpawnZ = gameProgress - maxSpawnDistance + tracker.minDistance + end + end + + -- Check if we need to spawn new obstacles + local currentSpawnZ = gameProgress + spawnDistance + local spawnCount = 0 -- Limit spawning to prevent memory issues + local maxSpawnsPerFrame = 10 -- Maximum obstacles to spawn per frame + + while currentSpawnZ < gameProgress + maxSpawnDistance and spawnCount < maxSpawnsPerFrame do + local newObstacles = spawnObstaclesAtPosition(currentSpawnZ) + + if #newObstacles > 0 then + -- Create a segment entry for tracking + local segment = { + zPosition = currentSpawnZ, + obstacles = newObstacles + } + table.insert(segments, segment) + spawnCount = spawnCount + #newObstacles + end + + currentSpawnZ = currentSpawnZ + 50 -- Increased spacing to reduce spawn frequency + end + + -- Remove old segments whose obstacles are all behind the player + for i = #segments, 1, -1 do + local segment = segments[i] + local allBehind = true + for _, obstacle in ipairs(segment.obstacles) do + if obstacle.Position.Z >= Player.Position.Z - 50 then + allBehind = false + break + end + end + if allBehind then + for _, obstacle in ipairs(segment.obstacles) do + if obstacle and obstacle.Parent then -- Check if obstacle still exists + World:RemoveChild(obstacle) + obstaclesByRef[obstacle] = nil + end + end + table.remove(segments, i) + end + end + + -- Additional cleanup: remove any obstacles that are too far behind + for obstacle, _ in pairs(obstaclesByRef) do + if obstacle and obstacle.Parent and obstacle.Position.Z < Player.Position.Z - 200 then + World:RemoveChild(obstacle) + obstaclesByRef[obstacle] = nil + end + end +end + + + + + From 0d084c334eec93efe9d0be76c6577246c296ecf4 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Fri, 27 Jun 2025 11:24:01 -0700 Subject: [PATCH 02/13] Cleanup / Organize, Check for impossible segments --- nickb30/game.lua | 558 ++++++++++++++++++++++++----------------------- 1 file changed, 290 insertions(+), 268 deletions(-) diff --git a/nickb30/game.lua b/nickb30/game.lua index d1589fe..669be2e 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -23,6 +23,18 @@ local LANE_WIDTH = 30 local BUILDING_FAR = 700 local DIFFICULTY_INCREASE_RATE = 0.02 -- How fast difficulty increases per second local MAX_DIFFICULTY_MULTIPLIER = 2.5 -- Maximum difficulty multiplier +local SWIPE_THRESHOLD = 10 -- Minimum distance for swipe detection +local GROUND_OFFSET = 0.1 -- Height offset for obstacles above ground +local WALL_SPACING = 50 -- Distance between walls in a train +local SPAWN_DISTANCE = 200 -- Distance ahead of current progress to spawn obstacles +local MAX_SPAWN_DISTANCE = 400 -- Maximum distance to spawn obstacles ahead +local SPAWN_SPACING = 50 -- Spacing between spawn attempts +local MAX_SPAWNS_PER_FRAME = 10 -- Maximum obstacles to spawn per frame +local CLEANUP_DISTANCE = 200 -- Distance behind player to clean up obstacles +local STAIRS_BOOST_MULTIPLIER = 1.5 -- Multiplier for stairs boost +local GROUND_MOTION_MULTIPLIER = 0.015 -- Multiplier for ground motion speed +local LANE_MOVEMENT_SPEED = 1000 -- Speed multiplier for lane movement +local LANE_MOVEMENT_THRESHOLD = 0.01 -- Threshold for lane movement completion local STATES = { LOADING = 1, MENU = 2, @@ -40,11 +52,6 @@ local COLLISION_GROUPS = { PLAYER = CollisionGroups(5), } --- SEGMENT SYSTEM -local SEGMENT_LENGTH = 120 -- How long each segment is (increased from 80) -local SEGMENTS_AHEAD = 3 -- How many segments to keep ahead of player -local MAX_Z_POSITION = 500 -- Maximum Z position before resetting (increased from 400) - -- Lane-based obstacle spawning system local laneTrackers = { left = { lastSpawnZ = 0, minDistance = 100, wallTrainCount = 0, stairsSpawned = false }, -- Left lane (-1) @@ -60,15 +67,6 @@ local obstacleTypes = { --{ type = "stairs", probability = 0.15, minDistance = 120 } } --- Obstacle combinations for multi-lane patterns -local combinationPatterns = { - { lanes = {-1, 1}, probability = 0.3, minDistance = 100 }, -- Side lanes - { lanes = {0}, probability = 0.4, minDistance = 80 }, -- Center lane - { lanes = {-1, 0, 1}, probability = 0.1, minDistance = 150 }, -- All lanes - { lanes = {-1, 0}, probability = 0.1, minDistance = 90 }, -- Left + center - { lanes = {0, 1}, probability = 0.1, minDistance = 90 } -- Center + right -} - -- obstacle parts local wallPart local flagPart @@ -77,7 +75,6 @@ local stairsPart -- Simple segment manager local segments = {} -- Active segments -local nextSegmentZ = 100 -- Z position for next segment (increased from 50) -- GAME STATE VARIABLES local downPos @@ -108,8 +105,6 @@ local highScoreText = nil local highScoreValueText = nil local newHighScoreText = nil local newHighScorePanel = nil -local coordinatesText = nil -local targetLaneText = nil local restartText = nil local currentState = STATES.LOADING local assetsLoaded = 0 @@ -247,6 +242,121 @@ local function updateHighScoreDisplay(newHighScore) end end +-- ============================================================================ +-- GAME STATE MANAGEMENT FUNCTIONS +-- ============================================================================ + +function dropPlayer() + Player.Position:Set(0, 40, 0) + Player.Rotation:Set(0, 0, 0) + Player.Velocity:Set(0, 0, 0) + + -- Clear segments using the new system + clearSegments() + + -- Reset game state + targetLane = 0 + currentLane = 0 + isGameOver = false + isSlowDownActive = false + slowDownTimer = 0 + gameSpeed = NORMAL_GAME_SPEED + gameProgress = 0 -- Reset game progress + difficultyMultiplier = 1.0 -- Reset difficulty + gameTime = 0 -- Reset game time + isCrouching = false -- Reset crouch state + crouchTimer = 0 + wantsToCrouch = false -- Reset air crouch state + Player.Scale.Y = NORMAL_SCALE -- Reset player scale + Player.Animations.Walk.Speed = ANIMATION_SPEED + Player.Animations.Walk:Stop() + Player.Motion.Y = 0 + score = 0 + currentState = STATES.READY -- Start in READY state instead of RUNNING + + -- Update UI displays + updateScoreDisplay(score) + + -- Hide new high score text + if newHighScoreText then + newHighScoreText.Text = "" + end + if newHighScorePanel then + newHighScorePanel.Color = Color(0, 0, 0, 0) -- Make transparent + end + + -- Hide restart text and show start instruction + if restartText then + restartText.Text = "Press W or swipe to start" + restartText.parentDidResize() + end +end + +function gameOver() + leaderboard:set({score = score, callback = function() + loadHighScore() + end}) + isGameOver = true + print("Game Over") + currentState = STATES.GAME_OVER + Player.Animations.Walk:Stop() + clearSegments() + + -- Check if this is a new high score + local currentHighScore = tonumber(highScoreValueText.Text) or 0 + if score > currentHighScore then + if newHighScoreText and newHighScorePanel then + newHighScoreText.Text = "NEW HIGH SCORE: " .. string.format("%.0f", score) + newHighScoreText.parentDidResize() + newHighScorePanel.Color = UI_COLORS.background + end + end + + -- Show restart instruction + if restartText then + restartText.Text = "Tap to restart" + restartText.parentDidResize() + end +end + +function restartGame() + print("Restarting game...") + currentState = STATES.RUNNING + isGameOver = false + + -- Hide restart instruction + if restartText then + restartText.Text = "" + end + + -- Call dropPlayer to reset everything + dropPlayer() +end + +function startGame() + print("Starting game...") + currentState = STATES.RUNNING + + -- Start player animation + Player.Animations.Walk:Play() + + -- Start motion on all existing obstacles + for _, segment in ipairs(segments) do + for _, obstacle in ipairs(segment.obstacles) do + obstacle.Motion.Z = -gameSpeed + end + end + + -- Hide start instruction + if restartText then + restartText.Text = "" + end +end + +-- ============================================================================ +-- PLAYER MOVEMENT AND CONTROLS +-- ============================================================================ + function startCrouch() if not isCrouching then if Player.IsOnGround then @@ -354,7 +464,6 @@ end -- Called when Pointer is "shown" (Pointer.IsHidden == false), which is the case by default. Pointer.Drag = function(pe) - print("Pointer.Drag called, currentState: " .. currentState) if currentState == STATES.GAME_OVER then restartGame() return @@ -381,23 +490,21 @@ Pointer.Drag = function(pe) if swipeTriggered == false then -- Swipe Right - if Xdiff > 50 and currentLane <= 0 then + if Xdiff > SWIPE_THRESHOLD and currentLane <= 0 then swipeTriggered = true - print("Swipe right") targetLane += 1 isMoving = true - elseif Xdiff < -50 and currentLane >= 0 then + elseif Xdiff < -SWIPE_THRESHOLD and currentLane >= 0 then swipeTriggered = true - print("Swipe left") targetLane -= 1 isMoving = true - elseif Ydiff > 50 then + elseif Ydiff > SWIPE_THRESHOLD then swipeTriggered = true if Player.IsOnGround then cancelCrouch() -- Cancel crouch when jumping Player.Velocity.Y = JUMP_STRENGTH end - elseif Ydiff < -50 then + elseif Ydiff < -SWIPE_THRESHOLD then swipeTriggered = true if not Player.IsOnGround then Player.Velocity.Y = -JUMP_STRENGTH -- Fall faster @@ -522,8 +629,8 @@ Client.OnStart = function() -- set collision box and groups local box = Box() box:Fit(wrapper, { recurse = true, localBox = true}) - box.Max -= Number3(0, 4, 0) - box.Max += Number3(5, 0, 0) + box.Max += Number3(5, -4, 0) + box.Min -= Number3(5, 0, 0) wrapper.CollisionBox = box wrapper.CollisionGroups = COLLISION_GROUPS.COLLIDERS + COLLISION_GROUPS.MOTION wrapper.CollidesWithGroups = COLLISION_GROUPS.PLAYER @@ -611,7 +718,7 @@ Client.OnStart = function() end end) - -- load stairs assest + -- load stairs asset HTTP:Get("https://files.blip.game/gltf/kenney/stairs.glb", function(response) if response.StatusCode == 200 then local req = Object:Load(response.Body, function(o) @@ -624,6 +731,11 @@ Client.OnStart = function() end) else print("Failed to load stairs asset, status: " .. response.StatusCode) + -- Continue without stairs if loading fails + assetsLoaded = assetsLoaded + 1 + if assetsLoaded == totalAssets then + currentState = STATES.MENU + end end end) @@ -684,18 +796,15 @@ Client.OnStart = function() -- ignore collisions with the ground if other.Physics == PhysicsMode.Trigger or other.Physics == PhysicsMode.Static then - --print("other rotation: " .. other.Rotation.X .. ", " .. other.Rotation.Y .. ", " .. other.Rotation.Z) if other.Parent ~= nil then -- Check if this is a stairs trigger local parent = other.Parent if obstaclesByRef[parent] == "stairs" then - --print("Stairs boost triggered!") -- Give the player a boost up and forward - Player.Motion.Y = gameSpeed * 1.5 -- Upward boost + Player.Motion.Y = gameSpeed * STAIRS_BOOST_MULTIPLIER -- Upward boost return end other = parent - print("other rotation: " .. other.Rotation.X .. ", " .. other.Rotation.Y .. ", " .. other.Rotation.Z) end end @@ -704,9 +813,6 @@ Client.OnStart = function() end local obstacleType = obstaclesByRef[other] - --print("Collision with " .. obstacleType) - -- print normal vector - --print("Normal: " .. normal.X .. ", " .. normal.Y .. ", " .. normal.Z) -- For all obstacles, use the original logic if isSlowDownActive and normal.Y == 0 or normal.Z < 0 then @@ -728,12 +834,10 @@ Client.OnStart = function() Player.OnCollisionEnd = function(self, other, normal) if other.Physics == PhysicsMode.Trigger or other.Physics == PhysicsMode.Static then - --print("other rotation: " .. other.Rotation.X .. ", " .. other.Rotation.Y .. ", " .. other.Rotation.Z) if other.Parent ~= nil then -- Check if this is a stairs trigger local parent = other.Parent if obstaclesByRef[parent] == "stairs" then - --print("Stairs boost ended!") Player.Motion.Y = 0 return end @@ -743,181 +847,110 @@ Client.OnStart = function() end end -function dropPlayer() - Player.Position:Set(0, 40, 0) - Player.Rotation:Set(0, 0, 0) - Player.Velocity:Set(0, 0, 0) - - -- Clear segments using the new system - clearSegments() - - -- Reset game state - targetLane = 0 - currentLane = 0 - isGameOver = false - isSlowDownActive = false - slowDownTimer = 0 - gameSpeed = NORMAL_GAME_SPEED - gameProgress = 0 -- Reset game progress - difficultyMultiplier = 1.0 -- Reset difficulty - gameTime = 0 -- Reset game time - isCrouching = false -- Reset crouch state - crouchTimer = 0 - wantsToCrouch = false -- Reset air crouch state - Player.Scale.Y = NORMAL_SCALE -- Reset player scale - Player.Animations.Walk.Speed = ANIMATION_SPEED - Player.Animations.Walk:Stop() - Player.Motion.Y = 0 - score = 0 - currentState = STATES.READY -- Start in READY state instead of RUNNING - - -- Update UI displays - updateScoreDisplay(score) - - -- Hide new high score text - if newHighScoreText then - newHighScoreText.Text = "" - end - if newHighScorePanel then - newHighScorePanel.Color = Color(0, 0, 0, 0) -- Make transparent - end - - -- Hide restart text and show start instruction - if restartText then - restartText.Text = "Press W or swipe to start" - restartText.parentDidResize() +function updateSegments(gameProgress) + -- Don't spawn obstacles if assets aren't loaded yet + if logPart == nil or wallPart == nil or flagPart == nil or stairsPart == nil then + return end -end - -function gameOver() - leaderboard:set({score = score, callback = function() - loadHighScore() - end}) - isGameOver = true - print("Game Over") - currentState = STATES.GAME_OVER - Player.Animations.Walk:Stop() - clearSegments() - -- Check if this is a new high score - local currentHighScore = tonumber(highScoreValueText.Text) or 0 - if score > currentHighScore then - if newHighScoreText and newHighScorePanel then - newHighScoreText.Text = "NEW HIGH SCORE: " .. string.format("%.0f", score) - newHighScoreText.parentDidResize() - newHighScorePanel.Color = UI_COLORS.background + -- Reset lane trackers if lastSpawnZ is too far behind the current progress + for _, tracker in pairs(laneTrackers) do + if gameProgress - tracker.lastSpawnZ > MAX_SPAWN_DISTANCE then + tracker.lastSpawnZ = gameProgress - MAX_SPAWN_DISTANCE + tracker.minDistance end end - -- Show restart instruction - if restartText then - restartText.Text = "Tap to restart" - restartText.parentDidResize() - end -end - -function restartGame() - print("Restarting game...") - currentState = STATES.RUNNING - isGameOver = false + -- Check if we need to spawn new obstacles + local currentSpawnZ = gameProgress + SPAWN_DISTANCE + local spawnCount = 0 -- Limit spawning to prevent memory issues - -- Hide restart instruction - if restartText then - restartText.Text = "" + while currentSpawnZ < gameProgress + MAX_SPAWN_DISTANCE and spawnCount < MAX_SPAWNS_PER_FRAME do + local newObstacles = spawnObstaclesAtPosition(currentSpawnZ) + + if newObstacles and #newObstacles > 0 then + -- Create a segment entry for tracking + local segment = { + zPosition = currentSpawnZ, + obstacles = newObstacles + } + table.insert(segments, segment) + spawnCount = spawnCount + #newObstacles + end + + currentSpawnZ = currentSpawnZ + SPAWN_SPACING -- Increased spacing to reduce spawn frequency end - - -- Call dropPlayer to reset everything - dropPlayer() -end -function startGame() - print("Starting game...") - currentState = STATES.RUNNING - - -- Start player animation - Player.Animations.Walk:Play() - - -- Start motion on all existing obstacles - for _, segment in ipairs(segments) do + -- Remove old segments whose obstacles are all behind the player + for i = #segments, 1, -1 do + local segment = segments[i] + local allBehind = true for _, obstacle in ipairs(segment.obstacles) do - obstacle.Motion.Z = -gameSpeed + if obstacle.Position.Z >= Player.Position.Z - 50 then + allBehind = false + break + end + end + if allBehind then + for _, obstacle in ipairs(segment.obstacles) do + if obstacle and obstacle.Parent then -- Check if obstacle still exists + World:RemoveChild(obstacle) + obstaclesByRef[obstacle] = nil + end + end + table.remove(segments, i) end end - -- Hide start instruction - if restartText then - restartText.Text = "" - else + -- Additional cleanup: remove any obstacles that are too far behind + for obstacle, _ in pairs(obstaclesByRef) do + if obstacle and obstacle.Parent and obstacle.Position.Z < Player.Position.Z - CLEANUP_DISTANCE then + World:RemoveChild(obstacle) + obstaclesByRef[obstacle] = nil + end end end -Client.Tick = function(dt) - if currentState == STATES.LOADING then - return - end - - if currentState == STATES.MENU then - -- In menu state, just spawn initial segments - updateSegments(gameProgress) - return - end - - if currentState == STATES.READY then - -- Update UI in ready state - updateScoreDisplay(score) - - -- Spawn segments but don't update score or move obstacles - updateSegments(gameProgress) - return - end - - if isGameOver then return end - - -- Update game progress based on time and game speed - gameProgress = gameProgress + (gameSpeed * dt) - - -- Update difficulty over time (only when game is running) - if currentState == STATES.RUNNING then - gameTime = gameTime + dt - difficultyMultiplier = math.min(MAX_DIFFICULTY_MULTIPLIER, 1.0 + (DIFFICULTY_INCREASE_RATE * gameTime)) - gameSpeed = NORMAL_GAME_SPEED * difficultyMultiplier +function wouldCreateImpossibleSegment(lane, obstacleType, zPosition) + -- If this isn't a wall, it won't create an impossible segment + if obstacleType ~= "wall" then + return false end - updateScore(dt) - updateScoreDisplay(score) - updateCrouch(dt) -- Update crouch timer - --coordinatesText.Text = string.format("Coordinates: (%.1f, %.1f, %.1f)", Player.Position.X, Player.Position.Y, Player.Position.Z) - --targetLaneText.Text = "Target Lane: " .. targetLane - - if isSlowDownActive then - slowDownTimer -= dt - Player.Animations.Walk.Speed = ANIMATION_SPEED * SLOW_DOWN_MULTIPLIER - gameSpeed = NORMAL_GAME_SPEED * SLOW_DOWN_MULTIPLIER - updateObstacleSpeed(gameSpeed) - if slowDownTimer <= 0 then - isSlowDownActive = false - gameSpeed = NORMAL_GAME_SPEED * difficultyMultiplier -- Use current difficulty multiplier - Player.Animations.Walk.Speed = ANIMATION_SPEED - updateObstacleSpeed(gameSpeed) + -- Check if there are wall trains in other lanes that would overlap with this position + local wallsInOtherLanes = 0 + for checkLane = -1, 1 do + if checkLane ~= lane then + local tracker = getLaneTracker(checkLane) + if tracker and tracker.wallTrainCount > 0 then + -- This lane is in a wall train, check if it overlaps with our target position + -- Wall trains span multiple Z positions, so we need to check the range + local wallTrainStartZ = tracker.lastSpawnZ - ((tracker.wallTrainCount - 1) * WALL_SPACING) + local wallTrainEndZ = tracker.lastSpawnZ + + -- Check if our target position overlaps with this wall train + if zPosition >= wallTrainStartZ and zPosition <= wallTrainEndZ then + wallsInOtherLanes = wallsInOtherLanes + 1 + end + end end end - - updateSegments(gameProgress) - groundImage.Offset.Y = groundImage.Offset.Y - dt * gameSpeed * 0.015 - yellowLineLeft.Offset.Y = yellowLineLeft.Offset.Y - dt * gameSpeed * 0.015 - yellowLineMiddle.Offset.Y = yellowLineMiddle.Offset.Y - dt * gameSpeed * 0.015 - yellowLineRight.Offset.Y = yellowLineRight.Offset.Y - dt * gameSpeed * 0.015 - - - if isMoving then - targetLane = math.max(-1, math.min(1, targetLane)) - targetPosition = lanePositions[targetLane + 2] - Player.Velocity.X = (targetPosition.X - Player.Position.X) * 1000 * dt - if math.abs(targetPosition.X - Player.Position.X) < 0.01 then - currentLane = targetLane - isMoving = false + + -- Also check existing obstacles in the world for walls at this Z position + for obstacle, obstacleType in pairs(obstaclesByRef) do + if obstacleType == "wall" and obstacle.Parent then + local obstacleLane = math.round(obstacle.Position.X / LANE_WIDTH) + if obstacleLane ~= lane then + -- Check if this wall is at or near our target Z position + local distance = math.abs(obstacle.Position.Z - zPosition) + if distance <= WALL_SPACING then + wallsInOtherLanes = wallsInOtherLanes + 1 + end + end end end + + -- If there are already walls in both other lanes at this Z position, adding a wall here would block all lanes + return wallsInOtherLanes >= 2 end function spawnObstaclesAtPosition(zPosition) @@ -929,6 +962,11 @@ function spawnObstaclesAtPosition(zPosition) if tracker and canSpawnInLane(lane, zPosition) then local obstacleData = selectObstacleType() + -- Check if spawning this obstacle would create an impossible segment + if wouldCreateImpossibleSegment(lane, obstacleData.type, zPosition) then + return + end + -- If it's a wall, start a wall train if obstacleData.type == "wall" and obstacleData.trainLength then local trainLength = math.random(obstacleData.trainLength[1], obstacleData.trainLength[2]) @@ -945,9 +983,8 @@ function spawnObstaclesAtPosition(zPosition) end -- Spawn all walls in the train at once with Z offsets - local wallSpacing = 50 -- Distance between walls for i = 1, trainLength do - local wallZ = zPosition + (i * wallSpacing) + local wallZ = zPosition + (i * WALL_SPACING) local wallObstacle = spawnObstacle("wall", lane, wallZ) if wallObstacle then table.insert(spawnedObstacles, wallObstacle) @@ -955,7 +992,7 @@ function spawnObstaclesAtPosition(zPosition) end -- Update the lane tracker - mark that this wall train is complete - tracker.lastSpawnZ = zPosition + ((trainLength - 1) * wallSpacing) + tracker.lastSpawnZ = zPosition + ((trainLength - 1) * WALL_SPACING) tracker.minDistance = obstacleData.minDistance tracker.wallTrainCount = 0 -- Reset wall train count after spawning else @@ -972,6 +1009,10 @@ function spawnObstaclesAtPosition(zPosition) return spawnedObstacles end +function setObstaclePosition(obstacle, lane, zPosition) + obstacle.Position = Number3(lane * LANE_WIDTH, groundLevel + GROUND_OFFSET, zPosition) +end + function spawnObstacle(obstacleType, lane, zPosition) local obstacle @@ -988,19 +1029,16 @@ function spawnObstacle(obstacleType, lane, zPosition) if obstacleType == "log" then obstacle = logPart:Copy({ includeChildren = true }) - obstacle.Position = Number3(lane * LANE_WIDTH, groundLevel + 0.1, zPosition) elseif obstacleType == "wall" then obstacle = wallPart:Copy({ includeChildren = true }) - obstacle.Position = Number3(lane * LANE_WIDTH, groundLevel + 0.1, zPosition) elseif obstacleType == "flag" then obstacle = flagPart:Copy({ includeChildren = true }) - obstacle.Position = Number3(lane * LANE_WIDTH, groundLevel + 0.1, zPosition) elseif obstacleType == "stairs" then obstacle = stairsPart:Copy({ includeChildren = true }) - obstacle.Position = Number3(lane * LANE_WIDTH, groundLevel + 0.1, zPosition) end if obstacle then + setObstaclePosition(obstacle, lane, zPosition) obstacle.Mass = 1000 if currentState == STATES.RUNNING then obstacle.Motion.Z = -gameSpeed @@ -1067,21 +1105,6 @@ function selectObstacleType() return obstacleTypes[1] end -function selectCombinationPattern() - local rand = math.random() - local cumulative = 0 - - for _, pattern in ipairs(combinationPatterns) do - cumulative = cumulative + pattern.probability - if rand <= cumulative then - return pattern - end - end - - -- Fallback to center lane if something goes wrong - return combinationPatterns[2] -end - function updateObstacleSpeed(newSpeed) for _, segment in ipairs(segments) do for _, obstacle in ipairs(segment.obstacles) do @@ -1098,7 +1121,6 @@ function clearSegments() end end segments = {} - nextSegmentZ = 100 -- Reset to starting position -- Reset lane trackers laneTrackers.left.lastSpawnZ = 0 @@ -1115,70 +1137,70 @@ function clearSegments() laneTrackers.right.stairsSpawned = false end -function updateSegments(gameProgress) - -- Don't spawn obstacles if assets aren't loaded yet - if logPart == nil or wallPart == nil or flagPart == nil or stairsPart == nil then +Client.Tick = function(dt) + if currentState == STATES.LOADING then return end - - -- Always keep obstacles spawning ahead of the current progress - local spawnDistance = 200 -- Distance ahead of current progress to spawn obstacles - local maxSpawnDistance = 400 -- Maximum distance to spawn obstacles ahead - - -- Reset lane trackers if lastSpawnZ is too far behind the current progress - for _, tracker in pairs(laneTrackers) do - if gameProgress - tracker.lastSpawnZ > maxSpawnDistance then - tracker.lastSpawnZ = gameProgress - maxSpawnDistance + tracker.minDistance - end + + if currentState == STATES.MENU then + -- In menu state, just spawn initial segments + updateSegments(gameProgress) + return end + + if currentState == STATES.READY then + -- Update UI in ready state + updateScoreDisplay(score) + + -- Spawn segments but don't update score or move obstacles + updateSegments(gameProgress) + return + end + + if isGameOver then return end - -- Check if we need to spawn new obstacles - local currentSpawnZ = gameProgress + spawnDistance - local spawnCount = 0 -- Limit spawning to prevent memory issues - local maxSpawnsPerFrame = 10 -- Maximum obstacles to spawn per frame + -- Update game progress based on time and game speed + gameProgress = gameProgress + (gameSpeed * dt) - while currentSpawnZ < gameProgress + maxSpawnDistance and spawnCount < maxSpawnsPerFrame do - local newObstacles = spawnObstaclesAtPosition(currentSpawnZ) - - if #newObstacles > 0 then - -- Create a segment entry for tracking - local segment = { - zPosition = currentSpawnZ, - obstacles = newObstacles - } - table.insert(segments, segment) - spawnCount = spawnCount + #newObstacles - end - - currentSpawnZ = currentSpawnZ + 50 -- Increased spacing to reduce spawn frequency + -- Update difficulty over time (only when game is running) + if currentState == STATES.RUNNING then + gameTime = gameTime + dt + difficultyMultiplier = math.min(MAX_DIFFICULTY_MULTIPLIER, 1.0 + (DIFFICULTY_INCREASE_RATE * gameTime)) + gameSpeed = NORMAL_GAME_SPEED * difficultyMultiplier end + + updateScore(dt) + updateScoreDisplay(score) + updateCrouch(dt) -- Update crouch timer - -- Remove old segments whose obstacles are all behind the player - for i = #segments, 1, -1 do - local segment = segments[i] - local allBehind = true - for _, obstacle in ipairs(segment.obstacles) do - if obstacle.Position.Z >= Player.Position.Z - 50 then - allBehind = false - break - end - end - if allBehind then - for _, obstacle in ipairs(segment.obstacles) do - if obstacle and obstacle.Parent then -- Check if obstacle still exists - World:RemoveChild(obstacle) - obstaclesByRef[obstacle] = nil - end - end - table.remove(segments, i) + if isSlowDownActive then + slowDownTimer -= dt + Player.Animations.Walk.Speed = ANIMATION_SPEED * SLOW_DOWN_MULTIPLIER + gameSpeed = NORMAL_GAME_SPEED * SLOW_DOWN_MULTIPLIER + updateObstacleSpeed(gameSpeed) + if slowDownTimer <= 0 then + isSlowDownActive = false + gameSpeed = NORMAL_GAME_SPEED * difficultyMultiplier -- Use current difficulty multiplier + Player.Animations.Walk.Speed = ANIMATION_SPEED + Player.Animations.Walk:Play() -- Ensure walk animation is playing + updateObstacleSpeed(gameSpeed) end end - - -- Additional cleanup: remove any obstacles that are too far behind - for obstacle, _ in pairs(obstaclesByRef) do - if obstacle and obstacle.Parent and obstacle.Position.Z < Player.Position.Z - 200 then - World:RemoveChild(obstacle) - obstaclesByRef[obstacle] = nil + + updateSegments(gameProgress) + groundImage.Offset.Y = groundImage.Offset.Y - dt * gameSpeed * GROUND_MOTION_MULTIPLIER + yellowLineLeft.Offset.Y = yellowLineLeft.Offset.Y - dt * gameSpeed * GROUND_MOTION_MULTIPLIER + yellowLineMiddle.Offset.Y = yellowLineMiddle.Offset.Y - dt * gameSpeed * GROUND_MOTION_MULTIPLIER + yellowLineRight.Offset.Y = yellowLineRight.Offset.Y - dt * gameSpeed * GROUND_MOTION_MULTIPLIER + + + if isMoving then + targetLane = math.max(-1, math.min(1, targetLane)) + targetPosition = lanePositions[targetLane + 2] + Player.Velocity.X = (targetPosition.X - Player.Position.X) * LANE_MOVEMENT_SPEED * dt + if math.abs(targetPosition.X - Player.Position.X) < LANE_MOVEMENT_THRESHOLD then + currentLane = targetLane + isMoving = false end end end From 3d164d29fc9aa04133de3f637e64e1d90e3fc6d9 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Fri, 27 Jun 2025 13:00:52 -0700 Subject: [PATCH 03/13] Leaderboard, Start / Restart Button --- nickb30/game.lua | 131 ++++++++++++++++++++--------------------------- 1 file changed, 55 insertions(+), 76 deletions(-) diff --git a/nickb30/game.lua b/nickb30/game.lua index 669be2e..15466f2 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -4,6 +4,7 @@ Modules = { ease = "ease", ui = "uikit", webquad = "github.com/aduermael/modzh/webquad:7fbc37d", + niceleaderboard = "github.com/aduermael/modzh/niceleaderboard:d1d7c49", } Config.Items = { @@ -22,7 +23,7 @@ local SLOW_DOWN_DURATION = 3.0 local LANE_WIDTH = 30 local BUILDING_FAR = 700 local DIFFICULTY_INCREASE_RATE = 0.02 -- How fast difficulty increases per second -local MAX_DIFFICULTY_MULTIPLIER = 2.5 -- Maximum difficulty multiplier +local MAX_DIFFICULTY_MULTIPLIER = 3.0 -- Maximum difficulty multiplier local SWIPE_THRESHOLD = 10 -- Minimum distance for swipe detection local GROUND_OFFSET = 0.1 -- Height offset for obstacles above ground local WALL_SPACING = 50 -- Distance between walls in a train @@ -105,10 +106,11 @@ local highScoreText = nil local highScoreValueText = nil local newHighScoreText = nil local newHighScorePanel = nil -local restartText = nil local currentState = STATES.LOADING local assetsLoaded = 0 local totalAssets = 4 -- log, wall, flag, stairs +local startButton = nil +local restartButton = nil -- UI STYLING CONSTANTS local UI_COLORS = { @@ -198,14 +200,6 @@ local function createHighScorePanel() highScoreValueText:parentDidResize() end -local function createRestartText() - restartText = createStyledText("", 20, UI_COLORS.primary, true) - restartText.parentDidResize = function() - restartText.pos = { Screen.Width / 2 - restartText.Width / 2, Screen.Height / 2 - restartText.Height / 2} - end - restartText:parentDidResize() -end - local function createNewHighScoreText() -- New High Score Background Panel newHighScorePanel = ui:createFrame() @@ -284,12 +278,10 @@ function dropPlayer() if newHighScorePanel then newHighScorePanel.Color = Color(0, 0, 0, 0) -- Make transparent end - - -- Hide restart text and show start instruction - if restartText then - restartText.Text = "Press W or swipe to start" - restartText.parentDidResize() - end + + if leaderboardUI then leaderboardUI:show() end + if startButton then startButton:show() end + if restartButton then restartButton:hide() end end function gameOver() @@ -302,6 +294,9 @@ function gameOver() Player.Animations.Walk:Stop() clearSegments() + -- Show leaderboard UI when game is over + leaderboardUI:show() + -- Check if this is a new high score local currentHighScore = tonumber(highScoreValueText.Text) or 0 if score > currentHighScore then @@ -312,11 +307,8 @@ function gameOver() end end - -- Show restart instruction - if restartText then - restartText.Text = "Tap to restart" - restartText.parentDidResize() - end + if restartButton then restartButton:show() end + if startButton then startButton:hide() end end function restartGame() @@ -324,11 +316,6 @@ function restartGame() currentState = STATES.RUNNING isGameOver = false - -- Hide restart instruction - if restartText then - restartText.Text = "" - end - -- Call dropPlayer to reset everything dropPlayer() end @@ -337,6 +324,9 @@ function startGame() print("Starting game...") currentState = STATES.RUNNING + -- Hide leaderboard UI when game starts running + leaderboardUI:hide() + -- Start player animation Player.Animations.Walk:Play() @@ -347,10 +337,8 @@ function startGame() end end - -- Hide start instruction - if restartText then - restartText.Text = "" - end + if startButton then startButton:hide() end + if restartButton then restartButton:hide() end end -- ============================================================================ @@ -403,26 +391,7 @@ if Client.IsMobile then Client.Action1 = nil else Client.DirectionalPad = function(x, y) - if currentState == STATES.GAME_OVER then - restartGame() - return - end - - if currentState == STATES.MENU then - -- Transition from MENU to READY - currentState = STATES.READY - if restartText then - restartText.Text = "Press W or swipe to start" - restartText.parentDidResize() - end - return - end - - if currentState == STATES.READY then - startGame() - return - end - + -- Only allow movement/crouch/jump, not game start/restart if x == 1 then targetLane += 1 isMoving = true @@ -437,7 +406,7 @@ else end elseif y == -1 then if not Player.IsOnGround then - Player.Velocity.Y = -JUMP_STRENGTH -- Fall faster + Player.Velocity.Y = -JUMP_STRENGTH * 1.8 -- Fall faster startCrouch() -- Mark that player wants to crouch when landing else startCrouch() @@ -464,26 +433,6 @@ end -- Called when Pointer is "shown" (Pointer.IsHidden == false), which is the case by default. Pointer.Drag = function(pe) - if currentState == STATES.GAME_OVER then - restartGame() - return - end - - if currentState == STATES.MENU then - -- Transition from MENU to READY - currentState = STATES.READY - if restartText then - restartText.Text = "Swipe or jump to start" - restartText.parentDidResize() - end - return - end - - if currentState == STATES.READY then - startGame() - return - end - local pos = Number2(pe.X, pe.Y) * Screen.Size local Xdiff = pos.X - downPos.X local Ydiff = pos.Y - downPos.Y @@ -507,14 +456,14 @@ Pointer.Drag = function(pe) elseif Ydiff < -SWIPE_THRESHOLD then swipeTriggered = true if not Player.IsOnGround then - Player.Velocity.Y = -JUMP_STRENGTH -- Fall faster + Player.Velocity.Y = -JUMP_STRENGTH * 1.8 -- Fall faster startCrouch() -- Mark that player wants to crouch when landing else startCrouch() end end end - end +end Client.OnWorldObjectLoad = function(o) if o.Name == "ground" then @@ -545,6 +494,11 @@ Client.OnStart = function() -- Collision Groups -- Leaderboard leaderboard = Leaderboard("default") + leaderboardUI = niceleaderboard({}) + leaderboardUI.Width = 200 + leaderboardUI.Height = 300 + leaderboardUI.Position = { Screen.Width / 2 - leaderboardUI.Width / 2, Screen.Height / 2 - leaderboardUI.Height / 2 } + leaderboardUI:reload() -- ground texture groundImage = webquad:create({ @@ -742,15 +696,14 @@ Client.OnStart = function() -- Create modern UI panels createScorePanel() createHighScorePanel() - createRestartText() createNewHighScoreText() -- Load high score with callback function loadHighScore() leaderboard:get({ mode = "best", - friends = true, - limit = 10, + friends = false, + limit = 5, callback = function(scores, err) if err == nil and scores then local playerHighScore = 0 @@ -845,6 +798,30 @@ Client.OnStart = function() end end end + + -- Create start button + startButton = ui:buttonPositive({content = "Start Game"}) + startButton.Width = 200 + startButton.Height = 50 + startButton.pos = { Screen.Width / 2 - startButton.Width / 2, Screen.Height / 2 - leaderboardUI.Height - 20 } + startButton.onRelease = function() + leaderboardUI:hide() + startButton:hide() + startGame() + end + startButton:show() + + -- Create restart button + restartButton = ui:buttonPositive({content = "Restart Game"}) + restartButton.Width = 200 + restartButton.Height = 50 + restartButton.pos = { Screen.Width / 2 - restartButton.Width / 2, Screen.Height / 2 - leaderboardUI.Height - 20 } + restartButton.onRelease = function() + leaderboardUI:hide() + restartButton:hide() + restartGame() + end + restartButton:hide() end function updateSegments(gameProgress) @@ -1164,6 +1141,7 @@ Client.Tick = function(dt) -- Update difficulty over time (only when game is running) if currentState == STATES.RUNNING then + leaderboardUI:hide() gameTime = gameTime + dt difficultyMultiplier = math.min(MAX_DIFFICULTY_MULTIPLIER, 1.0 + (DIFFICULTY_INCREASE_RATE * gameTime)) gameSpeed = NORMAL_GAME_SPEED * difficultyMultiplier @@ -1209,3 +1187,4 @@ end + From 60db65c4af0e0fd0c59764a3985d69399fbde004 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Tue, 8 Jul 2025 12:36:37 -0700 Subject: [PATCH 04/13] Cliffs / trees, better UI, improved recycling, modified camera --- nickb30/game.lua | 632 +++++++++++++++++++++++++++-------------------- 1 file changed, 366 insertions(+), 266 deletions(-) diff --git a/nickb30/game.lua b/nickb30/game.lua index 15466f2..773bc3c 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -8,17 +8,22 @@ Modules = { } Config.Items = { - --grass = "s12.grass_cubzh", + "littlecreator.lc_tree_01", + "sansyozh.tree", + "cawa2un.tree01", + "cawa2un.tree04", } --Dev.DisplayColliders = true +Config.ConstantAcceleration *= 2 -- CONSTANTS -local JUMP_STRENGTH = 100 +local GROUND_MOTION_MULTIPLIER = 1/64 -- NEEDS UPDATED VALUE +local JUMP_STRENGTH = 150 local SCORE_PER_SECOND = 100 local ANIMATION_SPEED = 1.5 local NORMAL_GAME_SPEED = 80 -local SLOW_DOWN_MULTIPLIER = 0.65 +local SLOW_DOWN_MULTIPLIER = 0.80 local SLOW_DOWN_DURATION = 3.0 local LANE_WIDTH = 30 local BUILDING_FAR = 700 @@ -31,9 +36,8 @@ local SPAWN_DISTANCE = 200 -- Distance ahead of current progress to spawn obsta local MAX_SPAWN_DISTANCE = 400 -- Maximum distance to spawn obstacles ahead local SPAWN_SPACING = 50 -- Spacing between spawn attempts local MAX_SPAWNS_PER_FRAME = 10 -- Maximum obstacles to spawn per frame -local CLEANUP_DISTANCE = 200 -- Distance behind player to clean up obstacles +local CLEANUP_DISTANCE = 80 -- Distance behind player to clean up obstacles local STAIRS_BOOST_MULTIPLIER = 1.5 -- Multiplier for stairs boost -local GROUND_MOTION_MULTIPLIER = 0.015 -- Multiplier for ground motion speed local LANE_MOVEMENT_SPEED = 1000 -- Speed multiplier for lane movement local LANE_MOVEMENT_THRESHOLD = 0.01 -- Threshold for lane movement completion local STATES = { @@ -73,6 +77,7 @@ local wallPart local flagPart local logPart local stairsPart +local cliffPart -- Simple segment manager local segments = {} -- Active segments @@ -101,154 +106,68 @@ local NORMAL_SCALE = 0.5 -- The player's normal scale local CROUCH_SCALE = 0.25 -- How much to scale down when crouching (50% of normal size) local wantsToCrouch = false -- Track if player wants to crouch while in air local scoreText = nil -local scoreValueText = nil -local highScoreText = nil -local highScoreValueText = nil local newHighScoreText = nil local newHighScorePanel = nil local currentState = STATES.LOADING local assetsLoaded = 0 -local totalAssets = 4 -- log, wall, flag, stairs +local totalAssets = 5 -- log, wall, flag, stairs, cliff local startButton = nil local restartButton = nil --- UI STYLING CONSTANTS -local UI_COLORS = { - primary = Color(255, 255, 255), -- White - secondary = Color(200, 200, 200), -- Light gray - accent = Color(255, 215, 0), -- Gold - background = Color(0, 0, 0, 0.8), -- Semi-transparent black - border = Color(255, 255, 255, 0.4), -- Semi-transparent white - shadow = Color(0, 0, 0, 0.3) -- Shadow color -} - -local UI_POSITIONS = { - scorePanel = {x = 20, y = 20}, - highScorePanel = {x = 20, y = 80} -} - --- UI Helper Functions -local function createStyledText(text, fontSize, color, isBold) - local textObj = ui:createText(text) - textObj.FontSize = fontSize or 16 - textObj.Color = color or UI_COLORS.primary - if isBold then - textObj.Font = "Bold" - end - return textObj -end -local function createScorePanel() - -- Score Panel Background - local scorePanel = ui:createFrame() - scorePanel.Size = {200, 65} - scorePanel.Color = UI_COLORS.background - scorePanel.BorderRadius = 12 - scorePanel.BorderColor = UI_COLORS.border - scorePanel.BorderWidth = 2 - - -- Score Label - scoreText = createStyledText("SCORE", 12, UI_COLORS.secondary, true) +local function createTopRightScore() + -- Create score text in top-right corner + scoreText = ui:createText("0", + { + size = "big", + color = Color.White, + bold = true, + outline = 0.4, + text + } + ) scoreText.parentDidResize = function() - scoreText.pos = {UI_POSITIONS.scorePanel.x + 15, UI_POSITIONS.scorePanel.y + 40} - end - - -- Score Value - scoreValueText = createStyledText("0", 24, UI_COLORS.primary, true) - scoreValueText.parentDidResize = function() - scoreValueText.pos = {UI_POSITIONS.scorePanel.x + 15, UI_POSITIONS.scorePanel.y + 10} - end - - -- Position panel background - scorePanel.parentDidResize = function() - scorePanel.pos = {UI_POSITIONS.scorePanel.x, UI_POSITIONS.scorePanel.y} + scoreText.pos = {Screen.Width - 55 - scoreText.Width, Screen.Height - 55 - scoreText.Height} end - - scorePanel:parentDidResize() scoreText:parentDidResize() - scoreValueText:parentDidResize() end -local function createHighScorePanel() - -- High Score Panel Background - local highScorePanel = ui:createFrame() - highScorePanel.Size = {200, 65} - highScorePanel.Color = UI_COLORS.background - highScorePanel.BorderRadius = 12 - highScorePanel.BorderColor = UI_COLORS.border - highScorePanel.BorderWidth = 2 - - -- High Score Label - highScoreText = createStyledText("BEST", 12, UI_COLORS.secondary, true) - highScoreText.parentDidResize = function() - highScoreText.pos = {UI_POSITIONS.highScorePanel.x + 15, UI_POSITIONS.highScorePanel.y + 40} - end - - -- High Score Value - highScoreValueText = createStyledText("0", 24, UI_COLORS.accent, true) - highScoreValueText.parentDidResize = function() - highScoreValueText.pos = {UI_POSITIONS.highScorePanel.x + 15, UI_POSITIONS.highScorePanel.y + 10} - end - - -- Position panel background - highScorePanel.parentDidResize = function() - highScorePanel.pos = {UI_POSITIONS.highScorePanel.x, UI_POSITIONS.highScorePanel.y} +local function updateScoreDisplay(newScore) + if scoreText then + scoreText.Text = string.format("%.0f", newScore) + scoreText:parentDidResize() -- Reposition after text change end - - highScorePanel:parentDidResize() - highScoreText:parentDidResize() - highScoreValueText:parentDidResize() end local function createNewHighScoreText() - -- New High Score Background Panel - newHighScorePanel = ui:createFrame() - newHighScorePanel.Size = {400, 60} - newHighScorePanel.Color = Color(0, 0, 0, 0) -- Start transparent - newHighScorePanel.BorderRadius = 12 - newHighScorePanel.BorderColor = UI_COLORS.accent - newHighScorePanel.BorderWidth = 3 - - newHighScoreText = createStyledText("", 32, UI_COLORS.accent, true) + -- Remove the background panel for the final score display + -- Only create the text object + newHighScoreText = ui:createText("", { + size = "big", + color = Color.White, + bold = true, + outline = 0.4, + }) newHighScoreText.parentDidResize = function() - newHighScoreText.pos = { Screen.Width / 2 - newHighScoreText.Width / 2, Screen.Height * 0.666 - newHighScoreText.Height / 2} - end - - -- Position background panel - newHighScorePanel.parentDidResize = function() - newHighScorePanel.pos = { Screen.Width / 2 - newHighScorePanel.Size.Width / 2, Screen.Height * 0.666 - newHighScorePanel.Size.Height / 2} + newHighScoreText.pos = { Screen.Width / 2 - newHighScoreText.Width / 2, Screen.Height * 0.8 - newHighScoreText.Height / 2} end - - newHighScorePanel:parentDidResize() newHighScoreText:parentDidResize() newHighScoreText.Text = "" -- Start hidden + newHighScorePanel = nil end -local function updateScoreDisplay(newScore) - if scoreValueText then - scoreValueText.Text = string.format("%.0f", newScore) - end -end - -local function updateHighScoreDisplay(newHighScore) - if highScoreValueText then - highScoreValueText.Text = string.format("%.0f", newHighScore) - end -end - --- ============================================================================ --- GAME STATE MANAGEMENT FUNCTIONS --- ============================================================================ +-- At the top of your file, add: +-- length of a cliff +local CLIFF_LENGTH = 85 +local CLIFF_SPAWN_INTERVAL = CLIFF_LENGTH - 15 -- match your cliff Z scale +local nextCliffSpawnZ = 0 +-- In dropPlayer, reset nextCliffSpawnZ to the player's Z position function dropPlayer() Player.Position:Set(0, 40, 0) Player.Rotation:Set(0, 0, 0) Player.Velocity:Set(0, 0, 0) - - -- Clear segments using the new system clearSegments() - - -- Reset game state targetLane = 0 currentLane = 0 isGameOver = false @@ -266,19 +185,15 @@ function dropPlayer() Player.Animations.Walk:Stop() Player.Motion.Y = 0 score = 0 - currentState = STATES.READY -- Start in READY state instead of RUNNING + currentState = STATES.READY + cliffSpawnZ = 0 + lastCliffSpawnZ = -math.huge -- Update UI displays updateScoreDisplay(score) - - -- Hide new high score text - if newHighScoreText then - newHighScoreText.Text = "" - end - if newHighScorePanel then - newHighScorePanel.Color = Color(0, 0, 0, 0) -- Make transparent - end - + if scoreText then scoreText.IsHidden = false end + if newHighScoreText then newHighScoreText.Text = "" end + if newHighScorePanel then newHighScorePanel.Color = Color(0, 0, 0, 0) end if leaderboardUI then leaderboardUI:show() end if startButton then startButton:show() end if restartButton then restartButton:hide() end @@ -292,19 +207,36 @@ function gameOver() print("Game Over") currentState = STATES.GAME_OVER Player.Animations.Walk:Stop() - clearSegments() + Player.Velocity = Number3(0, 0, 0) + -- stop all motions + for _, segment in ipairs(segments) do + for _, obstacle in ipairs(segment.obstacles) do + obstacle.Motion.Z = 0 + end + end + updateCliffMotion(0) + --clearSegments() -- Show leaderboard UI when game is over leaderboardUI:show() - -- Check if this is a new high score - local currentHighScore = tonumber(highScoreValueText.Text) or 0 - if score > currentHighScore then - if newHighScoreText and newHighScorePanel then - newHighScoreText.Text = "NEW HIGH SCORE: " .. string.format("%.0f", score) - newHighScoreText.parentDidResize() - newHighScorePanel.Color = UI_COLORS.background - end + -- Hide the score text in top-right + if scoreText then scoreText.IsHidden = true end + + -- Show final score in the center panel + if newHighScoreText and newHighScorePanel then + newHighScoreText.Text = "FINAL SCORE: " .. string.format("%.0f", score) + newHighScoreText.Color = Color.White + newHighScoreText.FontSize = 48 + newHighScoreText.Font = "Bold" + newHighScoreText.Outline = 0.4 + newHighScoreText.parentDidResize() + end + + -- Check if this is a new high score (simplified - just show final score for now) + if newHighScoreText then + newHighScoreText.Text = "FINAL SCORE: " .. string.format("%.0f", score) + newHighScoreText.parentDidResize() end if restartButton then restartButton:show() end @@ -315,28 +247,21 @@ function restartGame() print("Restarting game...") currentState = STATES.RUNNING isGameOver = false - - -- Call dropPlayer to reset everything dropPlayer() + nextCliffSpawnZ = Player.Position.Z -- Ensure cliff spawning resumes end function startGame() print("Starting game...") currentState = STATES.RUNNING - - -- Hide leaderboard UI when game starts running leaderboardUI:hide() - - -- Start player animation Player.Animations.Walk:Play() - - -- Start motion on all existing obstacles for _, segment in ipairs(segments) do for _, obstacle in ipairs(segment.obstacles) do obstacle.Motion.Z = -gameSpeed end end - + updateCliffMotion(gameSpeed) if startButton then startButton:hide() end if restartButton then restartButton:hide() end end @@ -479,6 +404,7 @@ end -- function executed when the game starts Client.OnStart = function() + Player.CollisionGroups = COLLISION_GROUPS.PLAYER Player.CollidesWithGroups = COLLISION_GROUPS.GROUND + COLLISION_GROUPS.COLLIDERS @@ -555,6 +481,18 @@ Client.OnStart = function() World:AddChild(yellowLineRight) yellowLineRight.Rotation = { math.pi * 0.5, 0, 0 } + -- Create ground motion tracker object + groundMotionTracker = Object() + groundMotionTracker.Physics = PhysicsMode.Dynamic + groundMotionTracker.Acceleration = -Config.ConstantAcceleration + groundMotionTracker.Position = Number3(0, 0, 0) + groundMotionTracker.Mass = 1 + groundMotionTracker.Motion.Z = -gameSpeed + groundMotionTracker.CollisionGroups = nil + groundMotionTracker.CollidesWithGroups = nil + World:AddChild(groundMotionTracker) + groundMotionLastZ = 0 + local function wrapMesh(mesh, scale, type) local wrapper = Object() wrapper:AddChild(mesh) @@ -570,6 +508,7 @@ Client.OnStart = function() mesh.Scale = scale local box = Box() box:Fit(wrapper, { recurse = true, localBox = true}) + box.Max -= Number3(0, 5, 2) wrapper.CollisionBox = box wrapper.CollisionGroups = COLLISION_GROUPS.COLLIDERS + COLLISION_GROUPS.MOTION wrapper.CollidesWithGroups = COLLISION_GROUPS.PLAYER @@ -627,7 +566,20 @@ Client.OnStart = function() wrapper:AddChild(trigger) trigger.CollisionGroups = nil trigger.CollidesWithGroups = COLLISION_GROUPS.PLAYER + + + elseif type == "cliff" then + -- set scale and rotation for cliff + local fixedRotation = Number3(0, 0, 0) + scale:Rotate(fixedRotation) + mesh.LocalRotation = fixedRotation + mesh.Scale = scale + wrapper.CollisionGroups = nil end + + wrapper:Recurse(function(o) + if o.Shadow ~= nil then o.Shadow = true end + end, { includeRoot = true }) return wrapper end @@ -636,7 +588,7 @@ Client.OnStart = function() if response.StatusCode == 200 then local req = Object:Load(response.Body, function(o) logPart = wrapMesh(o, Number3(30, 40, 40), "log") - print("Log part loaded.") + --print("Log part loaded.") assetsLoaded = assetsLoaded + 1 if assetsLoaded == totalAssets then currentState = STATES.MENU @@ -649,7 +601,7 @@ Client.OnStart = function() if response.StatusCode == 200 then local req = Object:Load(response.Body, function(o) wallPart = wrapMesh(o, Number3(20, 30, 50), "wall") - print("Wall part loaded.") + --print("Wall part loaded.") assetsLoaded = assetsLoaded + 1 if assetsLoaded == totalAssets then currentState = STATES.MENU @@ -663,7 +615,7 @@ Client.OnStart = function() if response.StatusCode == 200 then local req = Object:Load(response.Body, function(o) flagPart = wrapMesh(o, Number3(35, 35, 30), "flag") - print("Flag part loaded.") + --print("Flag part loaded.") assetsLoaded = assetsLoaded + 1 if assetsLoaded == totalAssets then currentState = STATES.MENU @@ -676,8 +628,13 @@ Client.OnStart = function() HTTP:Get("https://files.blip.game/gltf/kenney/stairs.glb", function(response) if response.StatusCode == 200 then local req = Object:Load(response.Body, function(o) - stairsPart = wrapMesh(o, Number3(100, 60, 30), "stairs") - print("Stairs part loaded.") + stairsPart = wrapMesh(o, Number3(80, 55, 30), "stairs") + o.Material = { + albedo = Color(180, 140, 90), + --metallic = 0.0, + --roughness = 0.2, + } + --print("Stairs part loaded.") assetsLoaded = assetsLoaded + 1 if assetsLoaded == totalAssets then currentState = STATES.MENU @@ -693,9 +650,29 @@ Client.OnStart = function() end end) + -- load cliff slope asset + HTTP:Get("https://files.blip.game/gltf/kenney/cliff-slope.glb", function(response) + if response.StatusCode == 200 then + local req = Object:Load(response.Body, function(o) + cliffPart = wrapMesh(o, Number3(CLIFF_LENGTH, 45, 35), "cliff") + o.Material = { + albedo = Color(120, 200, 120), + } + --print("Cliff part loaded.") + assetsLoaded = assetsLoaded + 1 + + -- Prepopulate the cliff pool after cliff asset is loaded + prepopulateCliffPool(10) -- Start with 20 cliffs in the pool + + if assetsLoaded == totalAssets then + currentState = STATES.MENU + end + end) + end + end) + -- Create modern UI panels - createScorePanel() - createHighScorePanel() + createTopRightScore() createNewHighScoreText() -- Load high score with callback @@ -714,11 +691,6 @@ Client.OnStart = function() break end end - updateHighScoreDisplay(playerHighScore) - -- print("Player's high score: " .. playerHighScore) - else - updateHighScoreDisplay(0) - -- print("No high score data available") end end }) @@ -740,7 +712,7 @@ Client.OnStart = function() positionTargetBackoffDistance = 60, -- camera then tries to backoff that distance, considering collision (increased from 40) positionTargetMinBackoffDistance = 30, -- minimum backoff distance (increased from 20) positionTargetMaxBackoffDistance = 120, -- maximum backoff distance (increased from 100) - rotationTarget = Player.Head, -- camera rotates to that rotation (or rotation of given object) + rotationTarget = Rotation(math.rad(20), 0, 0), -- camera rotates to that rotation (or rotation of given object) rigidity = 0.3, -- how fast the camera moves to the target (reduced for smoother movement) collidesWithGroups = nil, -- camera will not go through objects in these groups } @@ -803,7 +775,7 @@ Client.OnStart = function() startButton = ui:buttonPositive({content = "Start Game"}) startButton.Width = 200 startButton.Height = 50 - startButton.pos = { Screen.Width / 2 - startButton.Width / 2, Screen.Height / 2 - leaderboardUI.Height - 20 } + startButton.pos = { Screen.Width / 2 - startButton.Width / 2, Screen.Height / 2 - leaderboardUI.Height + 40 } startButton.onRelease = function() leaderboardUI:hide() startButton:hide() @@ -815,13 +787,44 @@ Client.OnStart = function() restartButton = ui:buttonPositive({content = "Restart Game"}) restartButton.Width = 200 restartButton.Height = 50 - restartButton.pos = { Screen.Width / 2 - restartButton.Width / 2, Screen.Height / 2 - leaderboardUI.Height - 20 } + restartButton.pos = { Screen.Width / 2 - restartButton.Width / 2, Screen.Height / 2 - leaderboardUI.Height + 40} restartButton.onRelease = function() leaderboardUI:hide() restartButton:hide() restartGame() + startGame() end restartButton:hide() + + function spawnTreesOnCliff(cliff) + -- Check if trees already exist by looking for tree children + if cliff.hasTrees then + return + end + + cliff.hasTrees = true + -- Place two trees at 1/3 and 2/3 along the local X axis of the cliff + local positions = { + -CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.3, + -CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.7, + -- -CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.25, + -- -CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.75 + } + + for _, x in ipairs(positions) do + local treeAsset = Config.Items[math.random(1, #Config.Items)] + local tree = Shape(treeAsset) + cliff:AddChild(tree) + tree.Name = "tree" -- Give trees a name for identification + tree.Pivot = {tree.Width * 0.5, 0, tree.Depth * 0.5} + tree.LocalPosition = Number3(x, 16, 0) + tree.Scale = Number3(1, 1, 0.7) + tree.CollisionGroups = nil + tree.CollidesWithGroups = nil + tree.Physics = PhysicsMode.Disabled + tree.Shadow = true + end + end end function updateSegments(gameProgress) @@ -857,32 +860,35 @@ function updateSegments(gameProgress) currentSpawnZ = currentSpawnZ + SPAWN_SPACING -- Increased spacing to reduce spawn frequency end - -- Remove old segments whose obstacles are all behind the player - for i = #segments, 1, -1 do - local segment = segments[i] - local allBehind = true - for _, obstacle in ipairs(segment.obstacles) do - if obstacle.Position.Z >= Player.Position.Z - 50 then - allBehind = false - break + -- Additional cleanup: remove any obstacles that are too far behind + for obstacle, _ in pairs(obstaclesByRef) do + if obstacle and obstacle.Parent and obstacle.Position.Z < -CLEANUP_DISTANCE then + World:RemoveChild(obstacle) + obstacle.IsHidden = true + local type = obstaclesByRef[obstacle] + if type and obstaclePools[type] then + table.insert(obstaclePools[type], obstacle) + if type == "cliff" then + --activeCliffCount -= 1 -- Decrement active count + end end - end - if allBehind then - for _, obstacle in ipairs(segment.obstacles) do - if obstacle and obstacle.Parent then -- Check if obstacle still exists - World:RemoveChild(obstacle) - obstaclesByRef[obstacle] = nil + -- Remove from segments + for _, segment in ipairs(segments) do + for i = #segment.obstacles, 1, -1 do + if segment.obstacles[i] == obstacle then + table.remove(segment.obstacles, i) + break + end end end - table.remove(segments, i) + obstaclesByRef[obstacle] = nil end end - - -- Additional cleanup: remove any obstacles that are too far behind - for obstacle, _ in pairs(obstaclesByRef) do - if obstacle and obstacle.Parent and obstacle.Position.Z < Player.Position.Z - CLEANUP_DISTANCE then - World:RemoveChild(obstacle) - obstaclesByRef[obstacle] = nil + + -- Remove empty segments + for i = #segments, 1, -1 do + if #segments[i].obstacles == 0 then + table.remove(segments, i) end end end @@ -930,50 +936,86 @@ function wouldCreateImpossibleSegment(lane, obstacleType, zPosition) return wallsInOtherLanes >= 2 end +-- Restore getPooledObstacle for pooling +function getPooledObstacle(obstacleType) + local pool = obstaclePools[obstacleType] + if pool and #pool > 0 then + local obj = table.remove(pool) + obj.IsHidden = false + if obstacleType == "cliff" then + -- print("Spawned cliff from pool (recycled)") + end + return obj + else + if obstacleType == "log" and logPart then + return logPart:Copy({ includeChildren = true }) + elseif obstacleType == "wall" and wallPart then + return wallPart:Copy({ includeChildren = true }) + elseif obstacleType == "flag" and flagPart then + return flagPart:Copy({ includeChildren = true }) + elseif obstacleType == "stairs" and stairsPart then + return stairsPart:Copy({ includeChildren = true }) + elseif obstacleType == "cliff" and cliffPart then + --print("Spawned new cliff (not recycled)") + return cliffPart:Copy({ includeChildren = true }) + end + end + return nil +end + +-- Restore spawnObstacle for lane obstacles +function spawnObstacle(obstacleType, lane, zPosition) + local obstacle = getPooledObstacle(obstacleType) + if not obstacle then + return nil + end + obstaclesByRef[obstacle] = obstacleType + setObstaclePosition(obstacle, lane, zPosition) + obstacle.Mass = 1000 + if currentState == STATES.RUNNING then + obstacle.Motion.Z = -gameSpeed + else + obstacle.Motion.Z = 0 + end + obstacle.Friction = 0 + obstacle.Acceleration = -Config.ConstantAcceleration + obstacle.Velocity = Number3(0, 0, 0) + World:AddChild(obstacle) + return obstacle +end + function spawnObstaclesAtPosition(zPosition) local spawnedObstacles = {} - - -- Check each lane for spawning + -- Lane obstacles for lane = -1, 1 do local tracker = getLaneTracker(lane) if tracker and canSpawnInLane(lane, zPosition) then local obstacleData = selectObstacleType() - - -- Check if spawning this obstacle would create an impossible segment if wouldCreateImpossibleSegment(lane, obstacleData.type, zPosition) then - return + return spawnedObstacles end - - -- If it's a wall, start a wall train if obstacleData.type == "wall" and obstacleData.trainLength then - local trainLength = math.random(obstacleData.trainLength[1], obstacleData.trainLength[2]) - tracker.wallTrainCount = trainLength - tracker.stairsSpawned = false - - -- 50% chance to spawn stairs at the start of the wall train - if math.random() <= 0.5 then - local stairsObstacle = spawnObstacle("stairs", lane, zPosition) - if stairsObstacle then - table.insert(spawnedObstacles, stairsObstacle) - tracker.stairsSpawned = true - end + local trainLength = math.random(obstacleData.trainLength[1], obstacleData.trainLength[2]) + tracker.wallTrainCount = trainLength + tracker.stairsSpawned = false + if math.random() <= 0.5 then + local stairsObstacle = spawnObstacle("stairs", lane, zPosition + 10) + if stairsObstacle then + table.insert(spawnedObstacles, stairsObstacle) + tracker.stairsSpawned = true end - - -- Spawn all walls in the train at once with Z offsets - for i = 1, trainLength do - local wallZ = zPosition + (i * WALL_SPACING) - local wallObstacle = spawnObstacle("wall", lane, wallZ) - if wallObstacle then - table.insert(spawnedObstacles, wallObstacle) - end + end + for i = 1, trainLength do + local wallZ = zPosition + (i * WALL_SPACING) + local wallObstacle = spawnObstacle("wall", lane, wallZ) + if wallObstacle then + table.insert(spawnedObstacles, wallObstacle) end - - -- Update the lane tracker - mark that this wall train is complete - tracker.lastSpawnZ = zPosition + ((trainLength - 1) * WALL_SPACING) - tracker.minDistance = obstacleData.minDistance - tracker.wallTrainCount = 0 -- Reset wall train count after spawning + end + tracker.lastSpawnZ = zPosition + ((trainLength - 1) * WALL_SPACING) + tracker.minDistance = obstacleData.minDistance + tracker.wallTrainCount = 0 else - -- For non-wall obstacles, spawn normally local obstacle = spawnObstacle(obstacleData.type, lane, zPosition) if obstacle then table.insert(spawnedObstacles, obstacle) @@ -987,52 +1029,41 @@ function spawnObstaclesAtPosition(zPosition) end function setObstaclePosition(obstacle, lane, zPosition) - obstacle.Position = Number3(lane * LANE_WIDTH, groundLevel + GROUND_OFFSET, zPosition) + -- set logs higher for now + local y = groundLevel + GROUND_OFFSET + if obstaclesByRef[obstacle] == "log" then + y += 3 + end + obstacle.Position = Number3(lane * LANE_WIDTH, y, zPosition) end -function spawnObstacle(obstacleType, lane, zPosition) - local obstacle - - -- Check if required assets are loaded - if obstacleType == "log" and logPart == nil then - return nil - elseif obstacleType == "wall" and wallPart == nil then - return nil - elseif obstacleType == "flag" and flagPart == nil then - return nil - elseif obstacleType == "stairs" and stairsPart == nil then - return nil - end - - if obstacleType == "log" then - obstacle = logPart:Copy({ includeChildren = true }) - elseif obstacleType == "wall" then - obstacle = wallPart:Copy({ includeChildren = true }) - elseif obstacleType == "flag" then - obstacle = flagPart:Copy({ includeChildren = true }) - elseif obstacleType == "stairs" then - obstacle = stairsPart:Copy({ includeChildren = true }) +-- Add at the top with other obstacle variables +obstaclePools = { + log = {}, + wall = {}, + flag = {}, + stairs = {}, + cliff = {}, +} + +-- Cliff management +local MAX_ACTIVE_CLIFFS = 20 -- Maximum number of active cliffs +local activeCliffCount = 0 + +-- Function to prepopulate the cliff pool +function prepopulateCliffPool(poolSize) + if not cliffPart then + print("Cannot prepopulate cliff pool - cliffPart not loaded yet") + return end - if obstacle then - setObstaclePosition(obstacle, lane, zPosition) - obstacle.Mass = 1000 - if currentState == STATES.RUNNING then - obstacle.Motion.Z = -gameSpeed - else - obstacle.Motion.Z = 0 - end - obstacle.Friction = 0 - obstacle.Acceleration = -Config.ConstantAcceleration - obstacle.Velocity = Number3(0, 0, 0) - - World:AddChild(obstacle) - obstaclesByRef[obstacle] = obstacleType - - return obstacle + --print("Prepopulating cliff pool with " .. poolSize .. " cliffs...") + for i = 1, poolSize do + local cliff = cliffPart:Copy({ includeChildren = true }) + cliff.IsHidden = true + table.insert(obstaclePools.cliff, cliff) end - - return nil + --print("Cliff pool prepopulated with " .. #obstaclePools.cliff .. " cliffs") end function getLaneTracker(lane) @@ -1090,15 +1121,32 @@ function updateObstacleSpeed(newSpeed) end end +-- Update clearSegments and cleanup code to return obstacles to the pool function clearSegments() for _, segment in ipairs(segments) do for _, obstacle in ipairs(segment.obstacles) do + if obstacle and obstacle.Parent then + World:RemoveChild(obstacle) + obstacle.IsHidden = true + local type = obstaclesByRef[obstacle] + if type and obstaclePools[type] then + table.insert(obstaclePools[type], obstacle) + end + obstaclesByRef[obstacle] = nil + end + end + end + -- Also recycle any remaining cliffs in the world (not in segments) + for obstacle, type in pairs(obstaclesByRef) do + if type == "cliff" and obstacle.Parent then + activeCliffCount -= 1 World:RemoveChild(obstacle) + obstacle.IsHidden = true + table.insert(obstaclePools.cliff, obstacle) obstaclesByRef[obstacle] = nil end end segments = {} - -- Reset lane trackers laneTrackers.left.lastSpawnZ = 0 laneTrackers.center.lastSpawnZ = 0 @@ -1114,6 +1162,15 @@ function clearSegments() laneTrackers.right.stairsSpawned = false end +-- Add a helper to update all cliff motions +function updateCliffMotion(newSpeed) + for obstacle, type in pairs(obstaclesByRef) do + if type == "cliff" then + obstacle.Motion.Z = -newSpeed + end + end +end + Client.Tick = function(dt) if currentState == STATES.LOADING then return @@ -1156,21 +1213,28 @@ Client.Tick = function(dt) Player.Animations.Walk.Speed = ANIMATION_SPEED * SLOW_DOWN_MULTIPLIER gameSpeed = NORMAL_GAME_SPEED * SLOW_DOWN_MULTIPLIER updateObstacleSpeed(gameSpeed) + updateCliffMotion(gameSpeed) if slowDownTimer <= 0 then isSlowDownActive = false gameSpeed = NORMAL_GAME_SPEED * difficultyMultiplier -- Use current difficulty multiplier Player.Animations.Walk.Speed = ANIMATION_SPEED Player.Animations.Walk:Play() -- Ensure walk animation is playing updateObstacleSpeed(gameSpeed) + updateCliffMotion(gameSpeed) end end updateSegments(gameProgress) - groundImage.Offset.Y = groundImage.Offset.Y - dt * gameSpeed * GROUND_MOTION_MULTIPLIER - yellowLineLeft.Offset.Y = yellowLineLeft.Offset.Y - dt * gameSpeed * GROUND_MOTION_MULTIPLIER - yellowLineMiddle.Offset.Y = yellowLineMiddle.Offset.Y - dt * gameSpeed * GROUND_MOTION_MULTIPLIER - yellowLineRight.Offset.Y = yellowLineRight.Offset.Y - dt * gameSpeed * GROUND_MOTION_MULTIPLIER + groundMotionTracker.Motion.Z = -gameSpeed + -- Calculate offset based on position delta + local dz = groundMotionTracker.Position.Z - (groundMotionLastZ or 0) + groundMotionLastZ = groundMotionTracker.Position.Z + -- Use dz to update groundImage and yellow line offsets + groundImage.Offset.Y = groundImage.Offset.Y + dz * GROUND_MOTION_MULTIPLIER + yellowLineLeft.Offset.Y = yellowLineLeft.Offset.Y + dz * GROUND_MOTION_MULTIPLIER + yellowLineMiddle.Offset.Y = yellowLineMiddle.Offset.Y + dz * GROUND_MOTION_MULTIPLIER + yellowLineRight.Offset.Y = yellowLineRight.Offset.Y + dz * GROUND_MOTION_MULTIPLIER if isMoving then targetLane = math.max(-1, math.min(1, targetLane)) @@ -1181,6 +1245,42 @@ Client.Tick = function(dt) isMoving = false end end + + local function getActiveCliffCountAndFurthestZ() + local count = 0 + local maxZ = 0 + for obstacle, type in pairs(obstaclesByRef) do + if type == "cliff" and obstacle.Parent then + count = count + 1 + if obstacle.Position.Z > maxZ then + maxZ = obstacle.Position.Z + end + end + end + return count, maxZ + end + + local count, furthestZ = getActiveCliffCountAndFurthestZ() + while count < MAX_ACTIVE_CLIFFS do + local spawnZ = (count == 0 and 0) or (furthestZ + CLIFF_SPAWN_INTERVAL) + local cliffRight = spawnObstacle("cliff", 2.1, spawnZ) + local cliffLeft = spawnObstacle("cliff", -2.1, spawnZ) + if cliffRight and cliffLeft then + cliffRight.Rotation = Number3(0, math.pi/2, 0) + cliffLeft.Rotation = Number3(0, -math.pi/2, 0) + if currentState == STATES.RUNNING then + cliffRight.Motion.Z = -gameSpeed + cliffLeft.Motion.Z = -gameSpeed + end + spawnTreesOnCliff(cliffRight) + spawnTreesOnCliff(cliffLeft) + count = count + 2 + furthestZ = spawnZ + --wned cliffs at Z: " .. spawnZ .. ", Pool size: " .. #obstaclePools.cliff .. ", Active cliffs: " .. count) + else + break + end + end end From 7e7031cbebacdf521093539ec88965361a903549 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Thu, 10 Jul 2025 14:44:54 -0700 Subject: [PATCH 05/13] Tutorial, ground textures, sounds --- nickb30/drive.lua | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 nickb30/drive.lua diff --git a/nickb30/drive.lua b/nickb30/drive.lua new file mode 100644 index 0000000..e69de29 From 9d7dec6cddedfb6a2a61af1200757f08ba69f553 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Fri, 11 Jul 2025 12:18:14 -0700 Subject: [PATCH 06/13] Polished sounds and gameplay --- nickb30/game.lua | 881 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 723 insertions(+), 158 deletions(-) diff --git a/nickb30/game.lua b/nickb30/game.lua index 773bc3c..56f6322 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -4,7 +4,7 @@ Modules = { ease = "ease", ui = "uikit", webquad = "github.com/aduermael/modzh/webquad:7fbc37d", - niceleaderboard = "github.com/aduermael/modzh/niceleaderboard:d1d7c49", + niceleaderboard = "github.com/aduermael/modzh/niceleaderboard:47c44c8", } Config.Items = { @@ -18,7 +18,7 @@ Config.Items = { Config.ConstantAcceleration *= 2 -- CONSTANTS -local GROUND_MOTION_MULTIPLIER = 1/64 -- NEEDS UPDATED VALUE +local GROUND_MOTION_MULTIPLIER = 1/1536 -- NEEDS UPDATED VALUE local JUMP_STRENGTH = 150 local SCORE_PER_SECOND = 100 local ANIMATION_SPEED = 1.5 @@ -37,9 +37,19 @@ local MAX_SPAWN_DISTANCE = 400 -- Maximum distance to spawn obstacles ahead local SPAWN_SPACING = 50 -- Spacing between spawn attempts local MAX_SPAWNS_PER_FRAME = 10 -- Maximum obstacles to spawn per frame local CLEANUP_DISTANCE = 80 -- Distance behind player to clean up obstacles -local STAIRS_BOOST_MULTIPLIER = 1.5 -- Multiplier for stairs boost +local STAIRS_BOOST_MULTIPLIER = 3.0 -- Multiplier for stairs boost (increased for high speeds) local LANE_MOVEMENT_SPEED = 1000 -- Speed multiplier for lane movement local LANE_MOVEMENT_THRESHOLD = 0.01 -- Threshold for lane movement completion + +-- Tutorial constants +local TUTORIAL_ENABLED = true -- Set to true to enable tutorial +local TUTORIAL_WALL_Z = 200 -- Z position for tutorial walls (increased from 50) +local TUTORIAL_LOG_Z = 500 -- Z position for tutorial logs (increased spacing) +local TUTORIAL_FLAG_Z = 700 -- Z position for tutorial flags (better spacing) +local TUTORIAL_COMPLETE_Z = 800 -- Z position where tutorial ends (increased from 350) + +local TUTORIAL_END_BUFFER = 80 -- how far past the flags before tutorial ends + local STATES = { LOADING = 1, MENU = 2, @@ -101,7 +111,7 @@ local difficultyMultiplier = 1.0 -- Current difficulty multiplier local gameTime = 0 -- Total time the game has been running local isCrouching = false local crouchTimer = 0 -local CROUCH_DURATION = 1.0 -- How long to stay crouched +local CROUCH_DURATION = 0.5 -- How long to stay crouched local NORMAL_SCALE = 0.5 -- The player's normal scale local CROUCH_SCALE = 0.25 -- How much to scale down when crouching (50% of normal size) local wantsToCrouch = false -- Track if player wants to crouch while in air @@ -110,13 +120,36 @@ local newHighScoreText = nil local newHighScorePanel = nil local currentState = STATES.LOADING local assetsLoaded = 0 -local totalAssets = 5 -- log, wall, flag, stairs, cliff +local totalAssets = 6 -- log, wall, flag, stairs, cliff, tutorial_completed local startButton = nil local restartButton = nil +-- Tutorial state variables +local tutorialState = 0 -- 0 = not started, 1 = walls, 2 = logs, 3 = flags, 4 = complete +local tutorialText = nil +local tutorialObstacles = {} -- Track tutorial obstacles for cleanup +local tutorialStarted = false +local tutorialCompleted = false + +-- Footstep sound variables +local footstepTimer = 0 +local FOOTSTEP_INTERVAL = 0.33 -- Time between footsteps in seconds +local lastPlayerOnGround = false +local lastConcreteSound = 0 -- Track last concrete sound time for cooldown +local lastConcreteLanding = 0 -- Track last concrete landing sound time for cooldown +local lastConcreteTransition = nil -- Track when player transitions to concrete + +-- Flashing effect variables +local flashTimer = 0 +local FLASH_INTERVAL = 0.1 -- Time between flash toggles in seconds +local isFlashing = false +local showLeaderboardTimer = 0 -- Timer for showing leaderboard after game over + +local OBSTACLE_SPAWN_Z_OFFSET = 850 local function createTopRightScore() -- Create score text in top-right corner + node = ui:frameTextBackground() scoreText = ui:createText("0", { size = "big", @@ -126,16 +159,19 @@ local function createTopRightScore() text } ) - scoreText.parentDidResize = function() - scoreText.pos = {Screen.Width - 55 - scoreText.Width, Screen.Height - 55 - scoreText.Height} + scoreText:setParent(node) + node.parentDidResize = function() + node.pos = {Screen.Width - 55 - node.Width, Screen.Height - 55 - node.Height} + node.size = {scoreText.Width + 12, scoreText.Height + 10} + scoreText.pos = {5, 5} end - scoreText:parentDidResize() + node:parentDidResize() end local function updateScoreDisplay(newScore) if scoreText then scoreText.Text = string.format("%.0f", newScore) - scoreText:parentDidResize() -- Reposition after text change + node.parentDidResize() -- Reposition after text change end end @@ -156,6 +192,36 @@ local function createNewHighScoreText() newHighScorePanel = nil end +local function createTutorialText() + tutorialText = ui:createText("", { + size = "medium", + color = Color.White, + bold = true, + outline = 0.4, + }) + tutorialText.parentDidResize = function() + tutorialText.pos = { Screen.Width / 2 - tutorialText.Width / 2, Screen.Height * 0.15 - tutorialText.Height / 2} + tutorialText.object.MaxWidth = Screen.Width * 0.8 + end + tutorialText:parentDidResize() + tutorialText.Text = "" + tutorialText.IsHidden = true +end + +local function showTutorialText(text) + if tutorialText then + tutorialText.Text = text + tutorialText.IsHidden = false + tutorialText:parentDidResize() + end +end + +local function hideTutorialText() + if tutorialText then + tutorialText.IsHidden = true + end +end + -- At the top of your file, add: -- length of a cliff local CLIFF_LENGTH = 85 @@ -188,6 +254,21 @@ function dropPlayer() currentState = STATES.READY cliffSpawnZ = 0 lastCliffSpawnZ = -math.huge + + -- Reset tutorial state for new game + tutorialState = 0 + tutorialStarted = false + -- tutorialCompleted should preserve the value from KeyValueStore + cleanupTutorialObstacles() + hideTutorialText() + + -- Reset footstep timer + footstepTimer = 0 + + -- Reset flashing state + flashTimer = 0 + isFlashing = false + Player.IsHidden = false -- Update UI displays updateScoreDisplay(score) @@ -202,12 +283,17 @@ end function gameOver() leaderboard:set({score = score, callback = function() loadHighScore() + -- Reload the leaderboard UI after the score is submitted + leaderboardUI:reload() end}) isGameOver = true + sfx("death_scream_guy_4", { Volume = 0.5, Pitch = math.random() * 0.5 + 0.8, Spatialized = false }) print("Game Over") currentState = STATES.GAME_OVER Player.Animations.Walk:Stop() Player.Velocity = Number3(0, 0, 0) + -- Ensure player is visible when game ends + Player.IsHidden = false -- stop all motions for _, segment in ipairs(segments) do for _, obstacle in ipairs(segment.obstacles) do @@ -215,14 +301,26 @@ function gameOver() end end updateCliffMotion(0) - --clearSegments() - + -- stop tutorial obstacles + for _, obstacle in ipairs(tutorialObstacles) do + if obstacle and obstacle.Parent then + obstacle.Motion.Z = 0 + end + end + -- stop all cliffs + for obstacle, type in pairs(obstaclesByRef) do + if type == "cliff" and obstacle and obstacle.Parent then + obstacle.Motion.Z = 0 + end + end -- Show leaderboard UI when game is over - leaderboardUI:show() - + -- add a 2 second delay before showing the leaderboard + showLeaderboardTimer = 2 -- Hide the score text in top-right - if scoreText then scoreText.IsHidden = true end - + if scoreText then + scoreText.IsHidden = true + node.IsHidden = true + end -- Show final score in the center panel if newHighScoreText and newHighScorePanel then newHighScoreText.Text = "FINAL SCORE: " .. string.format("%.0f", score) @@ -232,13 +330,11 @@ function gameOver() newHighScoreText.Outline = 0.4 newHighScoreText.parentDidResize() end - -- Check if this is a new high score (simplified - just show final score for now) if newHighScoreText then newHighScoreText.Text = "FINAL SCORE: " .. string.format("%.0f", score) newHighScoreText.parentDidResize() end - if restartButton then restartButton:show() end if startButton then startButton:hide() end end @@ -256,6 +352,11 @@ function startGame() currentState = STATES.RUNNING leaderboardUI:hide() Player.Animations.Walk:Play() + + -- Set high speed for testing + --gameSpeed = NORMAL_GAME_SPEED * 4.0 -- Start at 3x speed + --gameTime = 80 -- Simulate 60 seconds of gameplay for high difficulty + for _, segment in ipairs(segments) do for _, obstacle in ipairs(segment.obstacles) do obstacle.Motion.Z = -gameSpeed @@ -264,6 +365,13 @@ function startGame() updateCliffMotion(gameSpeed) if startButton then startButton:hide() end if restartButton then restartButton:hide() end + if scoreText then scoreText.IsHidden = false end + if node then node.IsHidden = false end + + -- Start tutorial if enabled + if TUTORIAL_ENABLED then + startTutorial() + end end -- ============================================================================ @@ -289,6 +397,9 @@ function cancelCrouch() isCrouching = false crouchTimer = 0 Player.Scale.Y = NORMAL_SCALE -- Return to normal size + + -- Check for collisions when manually uncrouching + checkForObstacleCollisions() end wantsToCrouch = false -- Also cancel any pending air crouch end @@ -307,6 +418,49 @@ function updateCrouch(dt) if crouchTimer <= 0 then isCrouching = false Player.Scale.Y = NORMAL_SCALE -- Return to normal size + + -- Check for collisions when uncrouching + checkForObstacleCollisions() + end + end +end + +-- Add a function to check for obstacle collisions - for flag and crouching +function checkForObstacleCollisions() + -- Check if player is colliding with any obstacles after uncrouching + for obstacle, obstacleType in pairs(obstaclesByRef) do + if obstacle and obstacle.Parent and obstacleType ~= "stairs" then + -- Get the obstacle's collision box + local obstacleBox = obstacle.CollisionBox + if obstacleBox then + -- Get player's collision box + local playerBox = Player.CollisionBox + if playerBox then + -- Check if the boxes overlap + local playerMin = Player.Position + playerBox.Min + local playerMax = Player.Position + playerBox.Max + local obstacleMin = obstacle.Position + obstacleBox.Min + local obstacleMax = obstacle.Position + obstacleBox.Max + + -- Check for overlap + if playerMin.X < obstacleMax.X and playerMax.X > obstacleMin.X and + playerMin.Y < obstacleMax.Y and playerMax.Y > obstacleMin.Y and + playerMin.Z < obstacleMax.Z and playerMax.Z > obstacleMin.Z then + -- Player is colliding with an obstacle, trigger game over + Player.Position.Y = 0 + + -- Play collision sound based on obstacle type + if obstacleType == "log" then + sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + else + sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + end + + gameOver() + return + end + end + end end end end @@ -316,13 +470,20 @@ if Client.IsMobile then Client.Action1 = nil else Client.DirectionalPad = function(x, y) + -- Only allow controls when game is running + if currentState ~= STATES.RUNNING then + return + end + -- Only allow movement/crouch/jump, not game start/restart if x == 1 then targetLane += 1 isMoving = true + sfx("whooshes_small_1", { Volume = 0.5, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) elseif x == -1 then targetLane -= 1 isMoving = true + sfx("whooshes_small_1", { Volume = 0.5, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) end if y == 1 then if Player.IsOnGround then @@ -353,11 +514,74 @@ Pointer.Cancel = function(pe) end function updateScore(dt) - score = score + (SCORE_PER_SECOND * dt) + -- Score increases based on game speed multiplier for higher difficulty = higher rewards + local scoreMultiplier = difficultyMultiplier or 1.0 + score = score + (SCORE_PER_SECOND * scoreMultiplier * dt) +end + +function updateFootsteps(dt) + -- Only play footsteps when game is running and player is on ground + if currentState == STATES.RUNNING and Player.IsOnGround then + footstepTimer = footstepTimer + dt + + -- Play footstep sound at regular intervals + if footstepTimer >= FOOTSTEP_INTERVAL then + if Player.Position.Y > 41 then + -- Player is on top of a wall - use concrete sound for walking + sfx("walk_concrete_1", { Volume = 0.3, Pitch = 2.5, Spatialized = false }) + else + -- Player is on ground - use grass sound for walking + sfx("walk_grass_1", { Volume = 0.4, Pitch = 2.5, Spatialized = false }) + end + footstepTimer = footstepTimer % FOOTSTEP_INTERVAL -- Use modulo instead of reset to 0 + end + else + -- Only reset timer when game is not running, not when player is in air + if currentState ~= STATES.RUNNING then + if footstepTimer > 0 then + print("Footstep timer reset: currentState=" .. currentState .. ", IsOnGround=" .. tostring(Player.IsOnGround)) + end + footstepTimer = 0 + end + end + + -- Play immediate sound when transitioning to concrete + if currentState == STATES.RUNNING and Player.IsOnGround and Player.Position.Y > 41 then + if not lastConcreteTransition or (os.clock() - lastConcreteTransition) > 0.1 then + if not lastConcreteTransition then + -- First time on concrete, play sound immediately + sfx("walk_concrete_1", { Volume = 0.3, Pitch = 2.5, Spatialized = false }) + end + lastConcreteTransition = os.clock() + end + else + lastConcreteTransition = nil + end +end + +function updateFlashing(dt) + if isSlowDownActive then + flashTimer = flashTimer + dt + + -- Toggle player visibility for flashing effect + if flashTimer >= FLASH_INTERVAL then + Player.IsHidden = not Player.IsHidden + flashTimer = 0 + end + else + -- Stop flashing and ensure player is visible when not slowed down + Player.IsHidden = false + flashTimer = 0 + end end -- Called when Pointer is "shown" (Pointer.IsHidden == false), which is the case by default. Pointer.Drag = function(pe) + -- Only allow controls when game is running + if currentState ~= STATES.RUNNING then + return + end + local pos = Number2(pe.X, pe.Y) * Screen.Size local Xdiff = pos.X - downPos.X local Ydiff = pos.Y - downPos.Y @@ -368,10 +592,12 @@ Pointer.Drag = function(pe) swipeTriggered = true targetLane += 1 isMoving = true + sfx("whooshes_small_1", { Volume = 0.5, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) elseif Xdiff < -SWIPE_THRESHOLD and currentLane >= 0 then swipeTriggered = true targetLane -= 1 isMoving = true + sfx("whooshes_small_1", { Volume = 0.5, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) elseif Ydiff > SWIPE_THRESHOLD then swipeTriggered = true if Player.IsOnGround then @@ -405,6 +631,26 @@ end -- function executed when the game starts Client.OnStart = function() + local store = KeyValueStore(Player.UserID) + --store:Set("tutorial_completed", false, function(success) end) + store:Get("tutorial_completed", function(success, results) + if success then + tutorialCompleted = results.tutorial_completed + assetsLoaded += 1 + if assetsLoaded == totalAssets then + currentState = STATES.MENU + end + -- Move the tutorial completion check here, after everything is loaded + print("Tutorial completed: " .. tostring(tutorialCompleted)) + if tutorialCompleted then + OBSTACLE_SPAWN_Z_OFFSET = 0 + TUTORIAL_ENABLED = false + print("Tutorial disabled due to previous completion") + end + print("KeyValueStore: " .. tostring(results.tutorial_completed)) + end + end) + Player.CollisionGroups = COLLISION_GROUPS.PLAYER Player.CollidesWithGroups = COLLISION_GROUPS.GROUND + COLLISION_GROUPS.COLLIDERS @@ -427,59 +673,48 @@ Client.OnStart = function() leaderboardUI:reload() -- ground texture - groundImage = webquad:create({ - color = Color.White, - url = "https://files.cu.bzh/textures/asphalt.png", - }) - local tiling = BUILDING_FAR / 32 - groundImage.Width = BUILDING_FAR * 2 - groundImage.Height = BUILDING_FAR * 2 - groundImage.Tiling = { tiling, tiling } - groundImage.Anchor = { 0.5, 0.5 } - groundImage.IsDoubleSided = false - groundImage.Position.Y = groundLevel - World:AddChild(groundImage) - groundImage.Rotation = { math.pi * 0.5, 0, 0 } - - -- yellow lines (placeoholder for lanes) - yellowLineLeft = webquad:create({ + groundImageMiddle = webquad:create({ color = Color.White, - url = "https://files.cu.bzh/textures/asphalt-yellow-lines.png", + url = "https://files.blip.game/textures/grass-with-path.jpg", }) - yellowLineLeft.Width = 3 - yellowLineLeft.Height = BUILDING_FAR * 2 - yellowLineLeft.Tiling = { 1, tiling } - yellowLineLeft.Anchor = { 0.5, 0.5 } - yellowLineLeft.IsDoubleSided = false - yellowLineLeft.Position = groundImage.Position + { -LANE_WIDTH, 0.1, 0 } - World:AddChild(yellowLineLeft) - yellowLineLeft.Rotation = { math.pi * 0.5, 0, 0 } - - yellowLineMiddle = webquad:create({ - color = Color.White, - url = "https://files.cu.bzh/textures/asphalt-yellow-lines.png", - }) - yellowLineMiddle.Width = 3 - yellowLineMiddle.Height = BUILDING_FAR * 2 - yellowLineMiddle.Tiling = { 1, tiling } - yellowLineMiddle.Anchor = { 0.5, 0.5 } - yellowLineMiddle.IsDoubleSided = false - yellowLineMiddle.Position = groundImage.Position + { 0, 0.1, 0 } - World:AddChild(yellowLineMiddle) - yellowLineMiddle.Rotation = { math.pi * 0.5, 0, 0 } - - yellowLineRight = webquad:create({ - color = Color.White, - url = "https://files.cu.bzh/textures/asphalt-yellow-lines.png", - }) - yellowLineRight.Width = 3 - yellowLineRight.Height = BUILDING_FAR * 2 - yellowLineRight.Tiling = { 1, tiling } - yellowLineRight.Anchor = { 0.5, 0.5 } - yellowLineRight.IsDoubleSided = false - yellowLineRight.Position = groundImage.Position + { LANE_WIDTH, 0.1, 0 } - World:AddChild(yellowLineRight) - yellowLineRight.Rotation = { math.pi * 0.5, 0, 0 } + groundImageMiddle.Width = LANE_WIDTH * 5 + groundImageMiddle.Height = BUILDING_FAR * 2 + tilingY = groundImageMiddle.Height / 1536 + groundImageMiddle.Tiling = { 5, tilingY } + groundImageMiddle.Anchor = { 0.5, 0.5 } + groundImageMiddle.IsDoubleSided = false + groundImageMiddle.Position = { 0, groundLevel, 0 } + World:AddChild(groundImageMiddle) + groundImageMiddle.Rotation = { math.pi * 0.5, 0, 0 } + + -- groundImageRight = webquad:create({ + -- color = Color.White, + -- url = "https://files.blip.game/textures/grass-with-path.jpg", + -- }) + -- local tiling = BUILDING_FAR / 32 + -- groundImageRight.Width = LANE_WIDTH + -- groundImageRight.Height = BUILDING_FAR * 2 + -- groundImageRight.Tiling = { tiling, tiling } + -- groundImageRight.Anchor = { 0.5, 0.5 } + -- groundImageRight.IsDoubleSided = false + -- groundImageRight.Position = { LANE_WIDTH, groundLevel, 0 } + -- World:AddChild(groundImageRight) + -- groundImageRight.Rotation = { math.pi * 0.5, 0, 0 } + + -- groundImageLeft = webquad:create({ + -- color = Color.White, + -- url = "https://files.blip.game/textures/grass-with-path.jpg", + -- }) + -- local tiling = BUILDING_FAR / 32 + -- groundImageLeft.Width = LANE_WIDTH + -- groundImageLeft.Height = BUILDING_FAR * 2 + -- groundImageLeft.Tiling = { tiling, tiling } + -- groundImageLeft.Anchor = { 0.5, 0.5 } + -- groundImageLeft.IsDoubleSided = false + -- groundImageLeft.Position = { -LANE_WIDTH, groundLevel, 0 } + -- World:AddChild(groundImageLeft) + -- groundImageLeft.Rotation = { math.pi * 0.5, 0, 0 } + -- Create ground motion tracker object groundMotionTracker = Object() @@ -561,7 +796,8 @@ Client.OnStart = function() trigger.Physics = PhysicsMode.Trigger local triggerBox = Box() triggerBox:Fit(wrapper, { recurse = true, localBox = true}) - triggerBox.Min.Y = triggerBox.Min.Y + 5 -- Start trigger a bit above ground + triggerBox.Min += Number3(0, 0, -8) + triggerBox.Max += Number3(0, 3, 0) trigger.CollisionBox = triggerBox wrapper:AddChild(trigger) trigger.CollisionGroups = nil @@ -650,30 +886,47 @@ Client.OnStart = function() end end) + + cliffPart = Quad() + cliffPart.Physics = PhysicsMode.Dynamic + cliffPart.Acceleration = -Config.ConstantAcceleration + cliffPart.CollisionGroups = nil + cliffPart.CollidesWithGroups = nil + cliffPart.Width = CLIFF_LENGTH + cliffPart.Height = 45 + cliffPart.Color = Color(120, 200, 120) + cliffPart.Rotation = { math.rad(90), 0, 0} + assetsLoaded = assetsLoaded + 1 + if assetsLoaded == totalAssets then + currentState = STATES.MENU + end + prepopulateCliffPool(10) + -- load cliff slope asset - HTTP:Get("https://files.blip.game/gltf/kenney/cliff-slope.glb", function(response) - if response.StatusCode == 200 then - local req = Object:Load(response.Body, function(o) - cliffPart = wrapMesh(o, Number3(CLIFF_LENGTH, 45, 35), "cliff") - o.Material = { - albedo = Color(120, 200, 120), - } - --print("Cliff part loaded.") - assetsLoaded = assetsLoaded + 1 + -- HTTP:Get("https://files.blip.game/gltf/kenney/cliff-slope.glb", function(response) + -- if response.StatusCode == 200 then + -- local req = Object:Load(response.Body, function(o) + -- cliffPart = wrapMesh(o, Number3(CLIFF_LENGTH, 45, 35), "cliff") + -- o.Material = { + -- albedo = Color(120, 200, 120), + -- } + -- --print("Cliff part loaded.") + -- assetsLoaded = assetsLoaded + 1 - -- Prepopulate the cliff pool after cliff asset is loaded - prepopulateCliffPool(10) -- Start with 20 cliffs in the pool + -- -- Prepopulate the cliff pool after cliff asset is loaded + -- prepopulateCliffPool(10) -- Start with 20 cliffs in the pool - if assetsLoaded == totalAssets then - currentState = STATES.MENU - end - end) - end - end) + -- if assetsLoaded == totalAssets then + -- currentState = STATES.MENU + -- end + -- end) + -- end + -- end) -- Create modern UI panels createTopRightScore() createNewHighScoreText() + createTutorialText() -- Load high score with callback function loadHighScore() @@ -718,15 +971,25 @@ Client.OnStart = function() } Player.OnCollisionBegin = function(self, other, normal) - -- ignore collisions with the ground + -- Check for landing sound when player hits ground from above + if normal.Y > 0.5 and currentState == STATES.RUNNING then + -- Player is landing on ground - use grass sound + if Player.Position.Y <= 41 then + sfx("walk_grass_1", { Volume = 0.4, Pitch = 2.5, Spatialized = false }) + -- Reset footstep timer when landing + footstepTimer = 0 + end + end if other.Physics == PhysicsMode.Trigger or other.Physics == PhysicsMode.Static then if other.Parent ~= nil then -- Check if this is a stairs trigger local parent = other.Parent if obstaclesByRef[parent] == "stairs" then - -- Give the player a boost up and forward - Player.Motion.Y = gameSpeed * STAIRS_BOOST_MULTIPLIER -- Upward boost + -- Give the player a boost up and forward, scaled by current game speed + -- At higher speeds, we need a stronger boost to clear obstacles + local boostStrength = gameSpeed * STAIRS_BOOST_MULTIPLIER + Player.Motion.Y = boostStrength -- Upward boost return end other = parent @@ -739,12 +1002,36 @@ Client.OnStart = function() local obstacleType = obstaclesByRef[other] - -- For all obstacles, use the original logic - if isSlowDownActive and normal.Y == 0 or normal.Z < 0 then + -- Don't kill player if they hit stairs (even from front) + if obstacleType == "stairs" then + return + end + + -- Check for front collision (always fatal) + if normal.Z < 0 then + -- Play collision sound based on obstacle type + if obstacleType == "log" then + sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + else + sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + end gameOver() return end - -- hit block from the right + + -- Check if player is already flashing and hits from side + if isSlowDownActive and normal.Y == 0 then + -- Play collision sound based on obstacle type + if obstacleType == "log" then + sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + else + sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + end + gameOver() + return + end + + -- hit block from the side (first hit) if normal.Y == 0 then if normal.X < 0 then targetLane -= 1 @@ -754,6 +1041,13 @@ Client.OnStart = function() end isSlowDownActive = true slowDownTimer = SLOW_DOWN_DURATION + + -- Play collision sound based on obstacle type + if obstacleType == "log" then + sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + else + sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + end end end @@ -817,22 +1111,39 @@ Client.OnStart = function() cliff:AddChild(tree) tree.Name = "tree" -- Give trees a name for identification tree.Pivot = {tree.Width * 0.5, 0, tree.Depth * 0.5} - tree.LocalPosition = Number3(x, 16, 0) + tree.LocalPosition = Number3(x, 16, 5) tree.Scale = Number3(1, 1, 0.7) tree.CollisionGroups = nil tree.CollidesWithGroups = nil tree.Physics = PhysicsMode.Disabled tree.Shadow = true + -- Counter-rotate the tree to stand upright despite cliff rotation + tree.LocalRotation = Number3(-math.pi/6, 0, 0) end end end function updateSegments(gameProgress) -- Don't spawn obstacles if assets aren't loaded yet - if logPart == nil or wallPart == nil or flagPart == nil or stairsPart == nil then + if assetsLoaded < totalAssets then return end + -- During tutorial, only clean up cliffs, let normal cleanup run for everything else + if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then + for obstacle, type in pairs(obstaclesByRef) do + if type == "cliff" and obstacle and obstacle.Parent and obstacle.Position.Z < -CLEANUP_DISTANCE then + World:RemoveChild(obstacle) + obstacle.IsHidden = true + if obstaclePools.cliff then + table.insert(obstaclePools.cliff, obstacle) + end + obstaclesByRef[obstacle] = nil + end + end + -- Do NOT return here; let normal cleanup logic run for flags and other obstacles + end + -- Reset lane trackers if lastSpawnZ is too far behind the current progress for _, tracker in pairs(laneTrackers) do if gameProgress - tracker.lastSpawnZ > MAX_SPAWN_DISTANCE then @@ -840,13 +1151,11 @@ function updateSegments(gameProgress) end end - -- Check if we need to spawn new obstacles - local currentSpawnZ = gameProgress + SPAWN_DISTANCE + -- Always spawn obstacles (with offset) + local currentSpawnZ = gameProgress + SPAWN_DISTANCE + OBSTACLE_SPAWN_Z_OFFSET local spawnCount = 0 -- Limit spawning to prevent memory issues - - while currentSpawnZ < gameProgress + MAX_SPAWN_DISTANCE and spawnCount < MAX_SPAWNS_PER_FRAME do + while currentSpawnZ < gameProgress + MAX_SPAWN_DISTANCE + OBSTACLE_SPAWN_Z_OFFSET and spawnCount < MAX_SPAWNS_PER_FRAME do local newObstacles = spawnObstaclesAtPosition(currentSpawnZ) - if newObstacles and #newObstacles > 0 then -- Create a segment entry for tracking local segment = { @@ -856,21 +1165,22 @@ function updateSegments(gameProgress) table.insert(segments, segment) spawnCount = spawnCount + #newObstacles end - currentSpawnZ = currentSpawnZ + SPAWN_SPACING -- Increased spacing to reduce spawn frequency end -- Additional cleanup: remove any obstacles that are too far behind - for obstacle, _ in pairs(obstaclesByRef) do - if obstacle and obstacle.Parent and obstacle.Position.Z < -CLEANUP_DISTANCE then + for obstacle, type in pairs(obstaclesByRef) do + local cleanupZ = -CLEANUP_DISTANCE + if type == "flag" then + cleanupZ = -120 + elseif type == "cliff" then + cleanupZ = -120 -- Keep cliffs visible longer + end + if obstacle and obstacle.Parent and obstacle.Position.Z < cleanupZ then World:RemoveChild(obstacle) obstacle.IsHidden = true - local type = obstaclesByRef[obstacle] if type and obstaclePools[type] then table.insert(obstaclePools[type], obstacle) - if type == "cliff" then - --activeCliffCount -= 1 -- Decrement active count - end end -- Remove from segments for _, segment in ipairs(segments) do @@ -970,6 +1280,14 @@ function spawnObstacle(obstacleType, lane, zPosition) return nil end obstaclesByRef[obstacle] = obstacleType + if obstacleType == "cliff" then + --print("Added cliff to obstaclesByRef, total cliffs: " .. (function() local count = 0; for _, type in pairs(obstaclesByRef) do if type == "cliff" then count = count + 1 end end; return count end)()) + -- Ensure cliff has proper physics setup + obstacle.Physics = PhysicsMode.Dynamic + obstacle.Mass = 1000 + obstacle.Friction = 0 + obstacle.Acceleration = -Config.ConstantAcceleration + end setObstaclePosition(obstacle, lane, zPosition) obstacle.Mass = 1000 if currentState == STATES.RUNNING then @@ -1164,13 +1482,275 @@ end -- Add a helper to update all cliff motions function updateCliffMotion(newSpeed) + local cliffCount = 0 for obstacle, type in pairs(obstaclesByRef) do if type == "cliff" then obstacle.Motion.Z = -newSpeed + cliffCount = cliffCount + 1 + if cliffCount == 1 then + -- print("Cliff position: " .. obstacle.Position.Z .. ", Motion.Z: " .. obstacle.Motion.Z) + end + end + end + if cliffCount > 0 then + --print("Updated " .. cliffCount .. " cliffs with speed: " .. newSpeed) + end +end + +-- Add this function after updateCliffMotion or near other update functions +function updateCliffs() + local function getActiveCliffCountAndFurthestZ() + local count = 0 + local maxZ = 0 + for obstacle, type in pairs(obstaclesByRef) do + if type == "cliff" and obstacle.Parent then + count = count + 1 + if obstacle.Position.Z > maxZ then + maxZ = obstacle.Position.Z + end + end + end + return count, maxZ + end + + local count, furthestZ = getActiveCliffCountAndFurthestZ() + if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then + --print("Tutorial cliffs - count: " .. count .. ", furthestZ: " .. furthestZ .. ", MAX_ACTIVE_CLIFFS: " .. MAX_ACTIVE_CLIFFS) + end + while count < MAX_ACTIVE_CLIFFS do + local spawnZ = (count == 0 and 0) or (furthestZ + CLIFF_SPAWN_INTERVAL) + if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then + --print("Attempting to spawn cliffs at Z: " .. spawnZ .. " (count: " .. count .. ")") + end + local cliffRight = spawnObstacle("cliff", 1.8, spawnZ) + local cliffLeft = spawnObstacle("cliff", -1.8, spawnZ) + if cliffRight and cliffLeft then + cliffRight.Rotation = Number3(math.pi/6, math.pi/2, 0) + cliffLeft.Rotation = Number3(math.pi/6, -math.pi/2, 0) + if currentState == STATES.RUNNING then + cliffRight.Motion.Z = -gameSpeed + cliffLeft.Motion.Z = -gameSpeed + else + cliffRight.Motion.Z = 0 + cliffLeft.Motion.Z = 0 + end + spawnTreesOnCliff(cliffRight) + spawnTreesOnCliff(cliffLeft) + count = count + 2 + furthestZ = spawnZ + if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then + --print("Successfully spawned tutorial cliffs at Z: " .. spawnZ) + end + else + if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then + --print("Failed to spawn cliffs at Z: " .. spawnZ) + end + break + end + end +end + +-- Tutorial functions +function startTutorial() + if not TUTORIAL_ENABLED then + return + end + + tutorialStarted = true + tutorialState = 1 + + -- Spawn all tutorial obstacles at once + spawnTutorialWalls() + spawnTutorialLogs() + spawnTutorialFlags() + if IsMobile then + showTutorialText("Swipe left and right to switch lanes") + else + showTutorialText("Press the right or left key to switch lanes") + end +end + +function spawnTutorialWalls() + -- Spawn 3 walls in the middle lane + for i = 1, 3 do + local wallZ = TUTORIAL_WALL_Z + (i - 1) * WALL_SPACING + local wall = spawnObstacle("wall", 0, wallZ) -- 0 = center lane + if wall then + table.insert(tutorialObstacles, wall) + if currentState == STATES.RUNNING then + wall.Motion.Z = -gameSpeed + else + wall.Motion.Z = 0 + end + end + end +end + +function spawnTutorialLogs() + -- Spawn logs in all three lanes + for lane = -1, 1 do + local log = spawnObstacle("log", lane, TUTORIAL_LOG_Z) + if log then + table.insert(tutorialObstacles, log) + if currentState == STATES.RUNNING then + log.Motion.Z = -gameSpeed + else + log.Motion.Z = 0 + end + end + end +end + +function spawnTutorialFlags() + -- Spawn flags in all three lanes + for lane = -1, 1 do + local flag = spawnObstacle("flag", lane, TUTORIAL_FLAG_Z) + if flag then + --print("Spawned flag in lane " .. lane .. " at Z: " .. flag.Position.Z) + table.insert(tutorialObstacles, flag) + if currentState == STATES.RUNNING then + flag.Motion.Z = -gameSpeed + else + flag.Motion.Z = 0 + end + else + print("Failed to spawn flag in lane " .. lane) + end + end +end + +function updateTutorial() + if not TUTORIAL_ENABLED or not tutorialStarted or tutorialCompleted then + return + end + + -- Check if tutorial obstacles have moved behind the player (Z < 0) + local wallsPassed = false + local logsPassed = false + local flagsPassed = false + + -- Check if walls have passed the player + for _, obstacle in ipairs(tutorialObstacles) do + if obstaclesByRef[obstacle] == "wall" and obstacle.Position.Z < 0 then + wallsPassed = true + break + end + end + + -- Check if logs have passed the player + for _, obstacle in ipairs(tutorialObstacles) do + if obstaclesByRef[obstacle] == "log" and obstacle.Position.Z < 0 then + logsPassed = true + break + end + end + + -- Check if flags have passed the player + for _, obstacle in ipairs(tutorialObstacles) do + if obstaclesByRef[obstacle] == "flag" and obstacle.Position.Z < 0 then + flagsPassed = true + --print("Flag passed player at Z: " .. obstacle.Position.Z) + break + end + end + + if tutorialState == 1 then + -- Check if player moved left or right from center lane + if currentLane ~= 0 then + -- Player moved left or right, but keep text until walls pass + if wallsPassed then + tutorialState = 2 + hideTutorialText() + if IsMobile then + showTutorialText("Swipe up to jump") + else + showTutorialText("Press the up key to jump") + end + end + elseif wallsPassed then + -- Player passed walls without moving, force them to move + tutorialState = 2 + hideTutorialText() + if IsMobile then + showTutorialText("Swipe up to jump") + else + showTutorialText("Press the up key to jump") + end + end + elseif tutorialState == 2 then + if not Player.IsOnGround then + if logsPassed then + tutorialState = 3 + hideTutorialText() + if IsMobile then + showTutorialText("Swipe down to crouch") + else + showTutorialText("Press the down key to crouch") + end + end + elseif logsPassed then + tutorialState = 3 + hideTutorialText() + if IsMobile then + showTutorialText("Swipe down to crouch") + else + showTutorialText("Press the down key to crouch") + end + end + elseif tutorialState == 3 then + -- Check if player crouched + local allFlagsBehind = true + for _, obstacle in ipairs(tutorialObstacles) do + if obstaclesByRef[obstacle] == "flag" and obstacle.Position.Z > -TUTORIAL_END_BUFFER then + allFlagsBehind = false + break + end + end + --print("Tutorial State 3 - flagsPassed: " .. tostring(flagsPassed) .. ", allFlagsBehind: " .. tostring(allFlagsBehind) .. ", isCrouching: " .. tostring(isCrouching)) + if isCrouching then + if flagsPassed and allFlagsBehind then + --print("Tutorial completing - player crouched and flags passed!") + tutorialState = 4 + hideTutorialText() + tutorialCompleted = true + cleanupTutorialObstacles() + end + elseif flagsPassed and allFlagsBehind then + --print("Tutorial completing - flags passed without crouching!") + tutorialState = 4 + hideTutorialText() + tutorialCompleted = true + TUTORIAL_ENABLED = false + OBSTACLE_SPAWN_Z_OFFSET = 0 + print("Tutorial ended!") + local store = KeyValueStore(Player.UserID) + store:Set("tutorial_completed", true, function(success) + if success then + --print("Tutorial completed saved") + else + --print("Tutorial completed not saved") + end + end) + cleanupTutorialObstacles() end end end +function cleanupTutorialObstacles() + for _, obstacle in ipairs(tutorialObstacles) do + if obstacle and obstacle.Parent then + World:RemoveChild(obstacle) + obstacle.IsHidden = true + local type = obstaclesByRef[obstacle] + if type and obstaclePools[type] then + table.insert(obstaclePools[type], obstacle) + end + obstaclesByRef[obstacle] = nil + end + end + tutorialObstacles = {} +end + Client.Tick = function(dt) if currentState == STATES.LOADING then return @@ -1179,19 +1759,29 @@ Client.Tick = function(dt) if currentState == STATES.MENU then -- In menu state, just spawn initial segments updateSegments(gameProgress) + updateCliffs() return end if currentState == STATES.READY then -- Update UI in ready state updateScoreDisplay(score) - -- Spawn segments but don't update score or move obstacles updateSegments(gameProgress) + updateCliffs() return end - if isGameOver then return end + if isGameOver then + -- Handle leaderboard timer even when game is over + if showLeaderboardTimer > 0 then + showLeaderboardTimer = showLeaderboardTimer - dt + if showLeaderboardTimer <= 0 then + leaderboardUI:show() + end + end + return + end -- Update game progress based on time and game speed gameProgress = gameProgress + (gameSpeed * dt) @@ -1207,34 +1797,44 @@ Client.Tick = function(dt) updateScore(dt) updateScoreDisplay(score) updateCrouch(dt) -- Update crouch timer + updateFootsteps(dt) -- Update footstep sounds + updateFlashing(dt) -- Update flashing effect if isSlowDownActive then slowDownTimer -= dt - Player.Animations.Walk.Speed = ANIMATION_SPEED * SLOW_DOWN_MULTIPLIER - gameSpeed = NORMAL_GAME_SPEED * SLOW_DOWN_MULTIPLIER - updateObstacleSpeed(gameSpeed) - updateCliffMotion(gameSpeed) if slowDownTimer <= 0 then isSlowDownActive = false - gameSpeed = NORMAL_GAME_SPEED * difficultyMultiplier -- Use current difficulty multiplier - Player.Animations.Walk.Speed = ANIMATION_SPEED - Player.Animations.Walk:Play() -- Ensure walk animation is playing - updateObstacleSpeed(gameSpeed) - updateCliffMotion(gameSpeed) end end updateSegments(gameProgress) + updateObstacleSpeed(gameSpeed) + updateCliffMotion(gameSpeed) + updateCliffs() groundMotionTracker.Motion.Z = -gameSpeed + -- Update tutorial + updateTutorial() + + -- Update tutorial obstacle speeds and cliff motion + if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then + for _, obstacle in ipairs(tutorialObstacles) do + if obstacle and obstacle.Parent then + obstacle.Motion.Z = -gameSpeed + end + end + -- Also update cliff motion during tutorial + updateCliffMotion(gameSpeed) + --print("Tutorial active - updating cliff motion with speed: " .. gameSpeed) + end + -- Calculate offset based on position delta local dz = groundMotionTracker.Position.Z - (groundMotionLastZ or 0) groundMotionLastZ = groundMotionTracker.Position.Z -- Use dz to update groundImage and yellow line offsets - groundImage.Offset.Y = groundImage.Offset.Y + dz * GROUND_MOTION_MULTIPLIER - yellowLineLeft.Offset.Y = yellowLineLeft.Offset.Y + dz * GROUND_MOTION_MULTIPLIER - yellowLineMiddle.Offset.Y = yellowLineMiddle.Offset.Y + dz * GROUND_MOTION_MULTIPLIER - yellowLineRight.Offset.Y = yellowLineRight.Offset.Y + dz * GROUND_MOTION_MULTIPLIER + groundImageMiddle.Offset.Y = groundImageMiddle.Offset.Y + dz * GROUND_MOTION_MULTIPLIER + -- groundImageRight.Offset.Y = groundImageRight.Offset.Y + dz * GROUND_MOTION_MULTIPLIER + -- groundImageLeft.Offset.Y = groundImageLeft.Offset.Y + dz * GROUND_MOTION_MULTIPLIER if isMoving then targetLane = math.max(-1, math.min(1, targetLane)) @@ -1245,42 +1845,6 @@ Client.Tick = function(dt) isMoving = false end end - - local function getActiveCliffCountAndFurthestZ() - local count = 0 - local maxZ = 0 - for obstacle, type in pairs(obstaclesByRef) do - if type == "cliff" and obstacle.Parent then - count = count + 1 - if obstacle.Position.Z > maxZ then - maxZ = obstacle.Position.Z - end - end - end - return count, maxZ - end - - local count, furthestZ = getActiveCliffCountAndFurthestZ() - while count < MAX_ACTIVE_CLIFFS do - local spawnZ = (count == 0 and 0) or (furthestZ + CLIFF_SPAWN_INTERVAL) - local cliffRight = spawnObstacle("cliff", 2.1, spawnZ) - local cliffLeft = spawnObstacle("cliff", -2.1, spawnZ) - if cliffRight and cliffLeft then - cliffRight.Rotation = Number3(0, math.pi/2, 0) - cliffLeft.Rotation = Number3(0, -math.pi/2, 0) - if currentState == STATES.RUNNING then - cliffRight.Motion.Z = -gameSpeed - cliffLeft.Motion.Z = -gameSpeed - end - spawnTreesOnCliff(cliffRight) - spawnTreesOnCliff(cliffLeft) - count = count + 2 - furthestZ = spawnZ - --wned cliffs at Z: " .. spawnZ .. ", Pool size: " .. #obstaclePools.cliff .. ", Active cliffs: " .. count) - else - break - end - end end @@ -1288,3 +1852,4 @@ end + From 75762195b2a74d7b3566ad9ed465dd143512cba2 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Mon, 14 Jul 2025 12:33:55 -0700 Subject: [PATCH 07/13] Spawning animations updatesegments() - where obstacles are being spawned - we want to limit the Z position of obstacles spawning to where the furthest cliff is --- nickb30/game.lua | 51 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/nickb30/game.lua b/nickb30/game.lua index 56f6322..962fb47 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -3,7 +3,7 @@ Modules = { controls = "controls", ease = "ease", ui = "uikit", - webquad = "github.com/aduermael/modzh/webquad:7fbc37d", + webquad = "github.com/aduermael/modzh/webquad:cc6dda1", niceleaderboard = "github.com/aduermael/modzh/niceleaderboard:47c44c8", } @@ -18,7 +18,7 @@ Config.Items = { Config.ConstantAcceleration *= 2 -- CONSTANTS -local GROUND_MOTION_MULTIPLIER = 1/1536 -- NEEDS UPDATED VALUE +local GROUND_MOTION_MULTIPLIER = 1/384 -- NEEDS UPDATED VALUE local JUMP_STRENGTH = 150 local SCORE_PER_SECOND = 100 local ANIMATION_SPEED = 1.5 @@ -33,7 +33,7 @@ local SWIPE_THRESHOLD = 10 -- Minimum distance for swipe detection local GROUND_OFFSET = 0.1 -- Height offset for obstacles above ground local WALL_SPACING = 50 -- Distance between walls in a train local SPAWN_DISTANCE = 200 -- Distance ahead of current progress to spawn obstacles -local MAX_SPAWN_DISTANCE = 400 -- Maximum distance to spawn obstacles ahead +local MAX_SPAWN_DISTANCE = 300 -- Maximum distance to spawn obstacles ahead local SPAWN_SPACING = 50 -- Spacing between spawn attempts local MAX_SPAWNS_PER_FRAME = 10 -- Maximum obstacles to spawn per frame local CLEANUP_DISTANCE = 80 -- Distance behind player to clean up obstacles @@ -147,6 +147,11 @@ local showLeaderboardTimer = 0 -- Timer for showing leaderboard after game over local OBSTACLE_SPAWN_Z_OFFSET = 850 +-- Table to track obstacles that are animating upwards +local obstacleAnimations = {} -- { [obstacle] = { targetY = number, duration = number, elapsed = number, startY = number } } +local OBSTACLE_SPAWN_ANIMATION_OFFSET = 20 -- How far below ground to start +local OBSTACLE_SPAWN_ANIMATION_DURATION = 0.3 -- Animation duration in seconds + local function createTopRightScore() -- Create score text in top-right corner node = ui:frameTextBackground() @@ -313,8 +318,7 @@ function gameOver() obstacle.Motion.Z = 0 end end - -- Show leaderboard UI when game is over - -- add a 2 second delay before showing the leaderboard + -- Show leaderboard UI and restart button after a delay showLeaderboardTimer = 2 -- Hide the score text in top-right if scoreText then @@ -335,7 +339,7 @@ function gameOver() newHighScoreText.Text = "FINAL SCORE: " .. string.format("%.0f", score) newHighScoreText.parentDidResize() end - if restartButton then restartButton:show() end + if restartButton then restartButton:hide() end if startButton then startButton:hide() end end @@ -673,13 +677,15 @@ Client.OnStart = function() leaderboardUI:reload() -- ground texture + -- 256 x 384 groundImageMiddle = webquad:create({ color = Color.White, - url = "https://files.blip.game/textures/grass-with-path.jpg", + url = "https://files.blip.game/textures/grass-with-path-2.jpg", + filtering = false, }) groundImageMiddle.Width = LANE_WIDTH * 5 groundImageMiddle.Height = BUILDING_FAR * 2 - tilingY = groundImageMiddle.Height / 1536 + tilingY = groundImageMiddle.Height / 384 groundImageMiddle.Tiling = { 5, tilingY } groundImageMiddle.Anchor = { 0.5, 0.5 } groundImageMiddle.IsDoubleSided = false @@ -1352,7 +1358,15 @@ function setObstaclePosition(obstacle, lane, zPosition) if obstaclesByRef[obstacle] == "log" then y += 3 end - obstacle.Position = Number3(lane * LANE_WIDTH, y, zPosition) + -- Always animate from below ground, even for recycled obstacles + local startY = y - OBSTACLE_SPAWN_ANIMATION_OFFSET + obstacle.Position = Number3(lane * LANE_WIDTH, startY, zPosition) + obstacleAnimations[obstacle] = { + targetY = y, + duration = OBSTACLE_SPAWN_ANIMATION_DURATION, + elapsed = 0, + startY = startY + } end -- Add at the top with other obstacle variables @@ -1756,6 +1770,24 @@ Client.Tick = function(dt) return end + -- Animate obstacles rising from below ground + for obstacle, anim in pairs(obstacleAnimations) do + if obstacle and obstacle.Parent then + anim.elapsed = anim.elapsed + dt + local t = math.min(anim.elapsed / anim.duration, 1) + -- Ease out cubic for smoothness + local easeT = 1 - (1 - t) * (1 - t) * (1 - t) + local newY = anim.startY + (anim.targetY - anim.startY) * easeT + obstacle.Position.Y = newY + if t >= 1 then + obstacle.Position.Y = anim.targetY + obstacleAnimations[obstacle] = nil + end + else + obstacleAnimations[obstacle] = nil + end + end + if currentState == STATES.MENU then -- In menu state, just spawn initial segments updateSegments(gameProgress) @@ -1778,6 +1810,7 @@ Client.Tick = function(dt) showLeaderboardTimer = showLeaderboardTimer - dt if showLeaderboardTimer <= 0 then leaderboardUI:show() + if restartButton then restartButton:show() end end end return From 3887216dd9636d77f49c918dcad3339025a9b1a1 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Mon, 14 Jul 2025 23:51:11 -0700 Subject: [PATCH 08/13] Fixed and improved obstacle generation --- nickb30/game.lua | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/nickb30/game.lua b/nickb30/game.lua index 962fb47..b1e4ac6 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -1158,9 +1158,20 @@ function updateSegments(gameProgress) end -- Always spawn obstacles (with offset) - local currentSpawnZ = gameProgress + SPAWN_DISTANCE + OBSTACLE_SPAWN_Z_OFFSET local spawnCount = 0 -- Limit spawning to prevent memory issues - while currentSpawnZ < gameProgress + MAX_SPAWN_DISTANCE + OBSTACLE_SPAWN_Z_OFFSET and spawnCount < MAX_SPAWNS_PER_FRAME do + -- Find the furthest cliff Z position + local furthestCliffZ = 0 + for obstacle, type in pairs(obstaclesByRef) do + if type == "cliff" and obstacle.Parent and obstacle.Position.Z > furthestCliffZ then + furthestCliffZ = obstacle.Position.Z + end + end + -- Calculate spawn range - start from game progress + spawn distance, but don't exceed furthest cliff + local spawnStartZ = gameProgress + SPAWN_DISTANCE + OBSTACLE_SPAWN_Z_OFFSET + local spawnEndZ = math.min(gameProgress + MAX_SPAWN_DISTANCE + OBSTACLE_SPAWN_Z_OFFSET, furthestCliffZ) + + local currentSpawnZ = spawnStartZ + while currentSpawnZ < spawnEndZ and spawnCount < MAX_SPAWNS_PER_FRAME do local newObstacles = spawnObstaclesAtPosition(currentSpawnZ) if newObstacles and #newObstacles > 0 then -- Create a segment entry for tracking From b3aad712556eb22386ae54122107a51a6d6cca06 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Tue, 15 Jul 2025 12:24:52 -0700 Subject: [PATCH 09/13] Improved obstacle generation, better footstep sounds and haptic feedback --- nickb30/drive.lua | 0 nickb30/game.lua | 290 +++++++++++++++++++++++----------------------- 2 files changed, 144 insertions(+), 146 deletions(-) delete mode 100644 nickb30/drive.lua diff --git a/nickb30/drive.lua b/nickb30/drive.lua deleted file mode 100644 index e69de29..0000000 diff --git a/nickb30/game.lua b/nickb30/game.lua index b1e4ac6..ecacb23 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -18,7 +18,7 @@ Config.Items = { Config.ConstantAcceleration *= 2 -- CONSTANTS -local GROUND_MOTION_MULTIPLIER = 1/384 -- NEEDS UPDATED VALUE +local GROUND_MOTION_MULTIPLIER = 4/384 -- NEEDS UPDATED VALUE local JUMP_STRENGTH = 150 local SCORE_PER_SECOND = 100 local ANIMATION_SPEED = 1.5 @@ -33,7 +33,7 @@ local SWIPE_THRESHOLD = 10 -- Minimum distance for swipe detection local GROUND_OFFSET = 0.1 -- Height offset for obstacles above ground local WALL_SPACING = 50 -- Distance between walls in a train local SPAWN_DISTANCE = 200 -- Distance ahead of current progress to spawn obstacles -local MAX_SPAWN_DISTANCE = 300 -- Maximum distance to spawn obstacles ahead +local MAX_SPAWN_DISTANCE = 400 -- Maximum distance to spawn obstacles ahead local SPAWN_SPACING = 50 -- Spacing between spawn attempts local MAX_SPAWNS_PER_FRAME = 10 -- Maximum obstacles to spawn per frame local CLEANUP_DISTANCE = 80 -- Distance behind player to clean up obstacles @@ -74,6 +74,15 @@ local laneTrackers = { right = { lastSpawnZ = 0, minDistance = 100, wallTrainCount = 0, stairsSpawned = false } -- Right lane (1) } +-- Debug function to print lane tracker values +local function printLaneTrackers() + print("=== LANE TRACKERS ===") + print("Left: lastSpawnZ=" .. laneTrackers.left.lastSpawnZ .. ", minDistance=" .. laneTrackers.left.minDistance) + print("Center: lastSpawnZ=" .. laneTrackers.center.lastSpawnZ .. ", minDistance=" .. laneTrackers.center.minDistance) + print("Right: lastSpawnZ=" .. laneTrackers.right.lastSpawnZ .. ", minDistance=" .. laneTrackers.right.minDistance) + print("====================") +end + -- Obstacle spawning probabilities and types local obstacleTypes = { { type = "log", probability = 0.4, minDistance = 80 }, @@ -135,9 +144,6 @@ local tutorialCompleted = false local footstepTimer = 0 local FOOTSTEP_INTERVAL = 0.33 -- Time between footsteps in seconds local lastPlayerOnGround = false -local lastConcreteSound = 0 -- Track last concrete sound time for cooldown -local lastConcreteLanding = 0 -- Track last concrete landing sound time for cooldown -local lastConcreteTransition = nil -- Track when player transitions to concrete -- Flashing effect variables local flashTimer = 0 @@ -145,6 +151,12 @@ local FLASH_INTERVAL = 0.1 -- Time between flash toggles in seconds local isFlashing = false local showLeaderboardTimer = 0 -- Timer for showing leaderboard after game over +-- Track last ground collider for footsteps +local lastGroundCollider = nil + +-- Track last ground obstacle type for footsteps +local lastGroundObstacleType = nil + local OBSTACLE_SPAWN_Z_OFFSET = 850 -- Table to track obstacles that are animating upwards @@ -353,6 +365,7 @@ end function startGame() print("Starting game...") + print("spawn offset: " .. OBSTACLE_SPAWN_Z_OFFSET) currentState = STATES.RUNNING leaderboardUI:hide() Player.Animations.Walk:Play() @@ -532,10 +545,10 @@ function updateFootsteps(dt) if footstepTimer >= FOOTSTEP_INTERVAL then if Player.Position.Y > 41 then -- Player is on top of a wall - use concrete sound for walking - sfx("walk_concrete_1", { Volume = 0.3, Pitch = 2.5, Spatialized = false }) + sfx("walk_concrete_1", { Volume = 0.3, Pitch = 2.3 + math.random() * 0.2, Spatialized = false }) else -- Player is on ground - use grass sound for walking - sfx("walk_grass_1", { Volume = 0.4, Pitch = 2.5, Spatialized = false }) + sfx("walk_grass_1", { Volume = 0.4, Pitch = 2.3 + math.random() * 0.2, Spatialized = false }) end footstepTimer = footstepTimer % FOOTSTEP_INTERVAL -- Use modulo instead of reset to 0 end @@ -548,19 +561,6 @@ function updateFootsteps(dt) footstepTimer = 0 end end - - -- Play immediate sound when transitioning to concrete - if currentState == STATES.RUNNING and Player.IsOnGround and Player.Position.Y > 41 then - if not lastConcreteTransition or (os.clock() - lastConcreteTransition) > 0.1 then - if not lastConcreteTransition then - -- First time on concrete, play sound immediately - sfx("walk_concrete_1", { Volume = 0.3, Pitch = 2.5, Spatialized = false }) - end - lastConcreteTransition = os.clock() - end - else - lastConcreteTransition = nil - end end function updateFlashing(dt) @@ -645,13 +645,14 @@ Client.OnStart = function() currentState = STATES.MENU end -- Move the tutorial completion check here, after everything is loaded - print("Tutorial completed: " .. tostring(tutorialCompleted)) + --print("Tutorial completed: " .. tostring(tutorialCompleted)) if tutorialCompleted then OBSTACLE_SPAWN_Z_OFFSET = 0 TUTORIAL_ENABLED = false - print("Tutorial disabled due to previous completion") + --print("Tutorial disabled due to previous completion") end - print("KeyValueStore: " .. tostring(results.tutorial_completed)) + --print("OBSTACLE_SPAWN_Z_OFFSET: " .. OBSTACLE_SPAWN_Z_OFFSET) + --print("KeyValueStore: " .. tostring(results.tutorial_completed)) end end) @@ -683,8 +684,9 @@ Client.OnStart = function() url = "https://files.blip.game/textures/grass-with-path-2.jpg", filtering = false, }) - groundImageMiddle.Width = LANE_WIDTH * 5 - groundImageMiddle.Height = BUILDING_FAR * 2 + groundImageMiddle.Width = LANE_WIDTH * 5 * 4 + groundImageMiddle.Height = BUILDING_FAR * 2 * 4 + groundImageMiddle.Scale = 1/4 tilingY = groundImageMiddle.Height / 384 groundImageMiddle.Tiling = { 5, tilingY } groundImageMiddle.Anchor = { 0.5, 0.5 } @@ -693,35 +695,6 @@ Client.OnStart = function() World:AddChild(groundImageMiddle) groundImageMiddle.Rotation = { math.pi * 0.5, 0, 0 } - -- groundImageRight = webquad:create({ - -- color = Color.White, - -- url = "https://files.blip.game/textures/grass-with-path.jpg", - -- }) - -- local tiling = BUILDING_FAR / 32 - -- groundImageRight.Width = LANE_WIDTH - -- groundImageRight.Height = BUILDING_FAR * 2 - -- groundImageRight.Tiling = { tiling, tiling } - -- groundImageRight.Anchor = { 0.5, 0.5 } - -- groundImageRight.IsDoubleSided = false - -- groundImageRight.Position = { LANE_WIDTH, groundLevel, 0 } - -- World:AddChild(groundImageRight) - -- groundImageRight.Rotation = { math.pi * 0.5, 0, 0 } - - -- groundImageLeft = webquad:create({ - -- color = Color.White, - -- url = "https://files.blip.game/textures/grass-with-path.jpg", - -- }) - -- local tiling = BUILDING_FAR / 32 - -- groundImageLeft.Width = LANE_WIDTH - -- groundImageLeft.Height = BUILDING_FAR * 2 - -- groundImageLeft.Tiling = { tiling, tiling } - -- groundImageLeft.Anchor = { 0.5, 0.5 } - -- groundImageLeft.IsDoubleSided = false - -- groundImageLeft.Position = { -LANE_WIDTH, groundLevel, 0 } - -- World:AddChild(groundImageLeft) - -- groundImageLeft.Rotation = { math.pi * 0.5, 0, 0 } - - -- Create ground motion tracker object groundMotionTracker = Object() groundMotionTracker.Physics = PhysicsMode.Dynamic @@ -892,42 +865,34 @@ Client.OnStart = function() end end) - - cliffPart = Quad() - cliffPart.Physics = PhysicsMode.Dynamic - cliffPart.Acceleration = -Config.ConstantAcceleration - cliffPart.CollisionGroups = nil - cliffPart.CollidesWithGroups = nil - cliffPart.Width = CLIFF_LENGTH - cliffPart.Height = 45 - cliffPart.Color = Color(120, 200, 120) - cliffPart.Rotation = { math.rad(90), 0, 0} - assetsLoaded = assetsLoaded + 1 - if assetsLoaded == totalAssets then - currentState = STATES.MENU - end - prepopulateCliffPool(10) - - -- load cliff slope asset - -- HTTP:Get("https://files.blip.game/gltf/kenney/cliff-slope.glb", function(response) - -- if response.StatusCode == 200 then - -- local req = Object:Load(response.Body, function(o) - -- cliffPart = wrapMesh(o, Number3(CLIFF_LENGTH, 45, 35), "cliff") - -- o.Material = { - -- albedo = Color(120, 200, 120), - -- } - -- --print("Cliff part loaded.") - -- assetsLoaded = assetsLoaded + 1 - - -- -- Prepopulate the cliff pool after cliff asset is loaded - -- prepopulateCliffPool(10) -- Start with 20 cliffs in the pool - - -- if assetsLoaded == totalAssets then - -- currentState = STATES.MENU - -- end - -- end) - -- end - -- end) + -- function to create cliff part texture + -- edit this function to put the webquad into an object, and return the object + function createCliffPart() + local cliffObj = Object() + local quad = webquad:create({ + url = "https://files.blip.game/textures/grass-tile.jpg", + filtering = false, + }) + quad.Physics = PhysicsMode.Disabled + --quad.CollisionGroups = nil + --quad.CollidesWithGroups = nil + quad.Width = CLIFF_LENGTH * 4 + quad.Height = 45 * 4 + quad.Scale = 1/4 + quad.Anchor = { 0.5, 0 } + -- quad.Rotation = { math.rad(90), 0, 0} + cliffObj:AddChild(quad) + cliffObj.Physics = PhysicsMode.Dynamic + cliffObj.Acceleration = -Config.ConstantAcceleration + cliffObj.CollisionGroups = nil + cliffObj.CollidesWithGroups = nil + assetsLoaded = assetsLoaded + 1 + if assetsLoaded == totalAssets then + currentState = STATES.MENU + end + return cliffObj + end + prepopulateCliffPool(20) -- Create modern UI panels createTopRightScore() @@ -958,6 +923,10 @@ Client.OnStart = function() -- Load the high score initially loadHighScore() + -- Debug: Print initial lane tracker values + -- print("Initial lane tracker values:") + -- printLaneTrackers() + Player.Animations.Walk.Speed = ANIMATION_SPEED Player.Animations.Walk:Play() -- print("Initial Player.Scale.Y:", Player.Scale.Y) @@ -979,11 +948,10 @@ Client.OnStart = function() Player.OnCollisionBegin = function(self, other, normal) -- Check for landing sound when player hits ground from above if normal.Y > 0.5 and currentState == STATES.RUNNING then - -- Player is landing on ground - use grass sound - if Player.Position.Y <= 41 then - sfx("walk_grass_1", { Volume = 0.4, Pitch = 2.5, Spatialized = false }) - -- Reset footstep timer when landing - footstepTimer = 0 + local obstacleType = obstaclesByRef[other] + if lastGroundObstacleType ~= obstacleType then + footstepTimer = FOOTSTEP_INTERVAL + lastGroundObstacleType = obstacleType end end @@ -1058,6 +1026,10 @@ Client.OnStart = function() end Player.OnCollisionEnd = function(self, other, normal) + local obstacleType = obstaclesByRef[other] + if lastGroundObstacleType == obstacleType then + lastGroundObstacleType = nil + end if other.Physics == PhysicsMode.Trigger or other.Physics == PhysicsMode.Static then if other.Parent ~= nil then -- Check if this is a stairs trigger @@ -1117,8 +1089,8 @@ Client.OnStart = function() cliff:AddChild(tree) tree.Name = "tree" -- Give trees a name for identification tree.Pivot = {tree.Width * 0.5, 0, tree.Depth * 0.5} - tree.LocalPosition = Number3(x, 16, 5) tree.Scale = Number3(1, 1, 0.7) + tree.LocalPosition = Number3(x, 16, 5) tree.CollisionGroups = nil tree.CollidesWithGroups = nil tree.Physics = PhysicsMode.Disabled @@ -1151,38 +1123,34 @@ function updateSegments(gameProgress) end -- Reset lane trackers if lastSpawnZ is too far behind the current progress + --[[ for _, tracker in pairs(laneTrackers) do if gameProgress - tracker.lastSpawnZ > MAX_SPAWN_DISTANCE then tracker.lastSpawnZ = gameProgress - MAX_SPAWN_DISTANCE + tracker.minDistance end end - - -- Always spawn obstacles (with offset) - local spawnCount = 0 -- Limit spawning to prevent memory issues - -- Find the furthest cliff Z position + ]] + -- get furthest Z position of cliffs local furthestCliffZ = 0 for obstacle, type in pairs(obstaclesByRef) do - if type == "cliff" and obstacle.Parent and obstacle.Position.Z > furthestCliffZ then + if type == "cliff" and obstacle.Position.Z > furthestCliffZ then furthestCliffZ = obstacle.Position.Z end end - -- Calculate spawn range - start from game progress + spawn distance, but don't exceed furthest cliff - local spawnStartZ = gameProgress + SPAWN_DISTANCE + OBSTACLE_SPAWN_Z_OFFSET - local spawnEndZ = math.min(gameProgress + MAX_SPAWN_DISTANCE + OBSTACLE_SPAWN_Z_OFFSET, furthestCliffZ) + -- Always spawn obstacles (with offset) + local currentSpawnZ = gameProgress + SPAWN_DISTANCE + OBSTACLE_SPAWN_Z_OFFSET + currentSpawnZ = math.min(currentSpawnZ, furthestCliffZ + OBSTACLE_SPAWN_Z_OFFSET - 30) - local currentSpawnZ = spawnStartZ - while currentSpawnZ < spawnEndZ and spawnCount < MAX_SPAWNS_PER_FRAME do - local newObstacles = spawnObstaclesAtPosition(currentSpawnZ) - if newObstacles and #newObstacles > 0 then - -- Create a segment entry for tracking - local segment = { - zPosition = currentSpawnZ, - obstacles = newObstacles - } - table.insert(segments, segment) - spawnCount = spawnCount + #newObstacles - end - currentSpawnZ = currentSpawnZ + SPAWN_SPACING -- Increased spacing to reduce spawn frequency + -- Spawn obstacles at the current position (no loop needed since this runs every frame) + --print("Attempting to spawn obstacles at Z: " .. currentSpawnZ) + local newObstacles = spawnObstaclesAtPosition(currentSpawnZ) + if newObstacles and #newObstacles > 0 then + -- Create a segment entry for tracking + local segment = { + zPosition = currentSpawnZ, + obstacles = newObstacles + } + table.insert(segments, segment) end -- Additional cleanup: remove any obstacles that are too far behind @@ -1284,7 +1252,8 @@ function getPooledObstacle(obstacleType) return stairsPart:Copy({ includeChildren = true }) elseif obstacleType == "cliff" and cliffPart then --print("Spawned new cliff (not recycled)") - return cliffPart:Copy({ includeChildren = true }) + return createCliffPart() + -- cliffPart:Copy({ includeChildren = true }) end end return nil @@ -1298,8 +1267,6 @@ function spawnObstacle(obstacleType, lane, zPosition) end obstaclesByRef[obstacle] = obstacleType if obstacleType == "cliff" then - --print("Added cliff to obstaclesByRef, total cliffs: " .. (function() local count = 0; for _, type in pairs(obstaclesByRef) do if type == "cliff" then count = count + 1 end end; return count end)()) - -- Ensure cliff has proper physics setup obstacle.Physics = PhysicsMode.Dynamic obstacle.Mass = 1000 obstacle.Friction = 0 @@ -1324,6 +1291,7 @@ function spawnObstaclesAtPosition(zPosition) -- Lane obstacles for lane = -1, 1 do local tracker = getLaneTracker(lane) + --print("Can spawn in lane: " .. tostring(canSpawnInLane(lane, zPosition))) if tracker and canSpawnInLane(lane, zPosition) then local obstacleData = selectObstacleType() if wouldCreateImpossibleSegment(lane, obstacleData.type, zPosition) then @@ -1347,7 +1315,8 @@ function spawnObstaclesAtPosition(zPosition) table.insert(spawnedObstacles, wallObstacle) end end - tracker.lastSpawnZ = zPosition + ((trainLength - 1) * WALL_SPACING) + tracker.lastSpawnZ = zPosition + ((trainLength) * WALL_SPACING) + --print("Set lastSpawnZ to: " .. tracker.lastSpawnZ) tracker.minDistance = obstacleData.minDistance tracker.wallTrainCount = 0 else @@ -1355,6 +1324,7 @@ function spawnObstaclesAtPosition(zPosition) if obstacle then table.insert(spawnedObstacles, obstacle) tracker.lastSpawnZ = zPosition + --print("Set lastSpawnZ to: " .. tracker.lastSpawnZ) tracker.minDistance = obstacleData.minDistance end end @@ -1395,14 +1365,14 @@ local activeCliffCount = 0 -- Function to prepopulate the cliff pool function prepopulateCliffPool(poolSize) - if not cliffPart then + if not createCliffPart() then print("Cannot prepopulate cliff pool - cliffPart not loaded yet") return end --print("Prepopulating cliff pool with " .. poolSize .. " cliffs...") for i = 1, poolSize do - local cliff = cliffPart:Copy({ includeChildren = true }) + local cliff = createCliffPart() cliff.IsHidden = true table.insert(obstaclePools.cliff, cliff) end @@ -1420,6 +1390,28 @@ function getLaneTracker(lane) return nil end +function updateLaneTrackerLastSpawnZ(lane) + local tracker = getLaneTracker(lane) + if not tracker then return end + + -- Find the furthest obstacle in this lane + local furthestZ = -math.huge + for obstacle, obstacleType in pairs(obstaclesByRef) do + if obstacle and obstacle.Parent and obstacleType ~= "cliff" then + -- Check if this obstacle is in the correct lane + local obstacleLane = math.round(obstacle.Position.X / LANE_WIDTH) + if obstacleLane == lane and obstacle.Position.Z > furthestZ then + furthestZ = obstacle.Position.Z + end + end + end + + -- If we found obstacles in this lane, update lastSpawnZ + if furthestZ > -math.huge then + tracker.lastSpawnZ = furthestZ + end +end + function canSpawnInLane(lane, currentZ) local tracker = getLaneTracker(lane) if not tracker then return false end @@ -1429,7 +1421,11 @@ function canSpawnInLane(lane, currentZ) return true end + -- Update lastSpawnZ based on current obstacle positions + updateLaneTrackerLastSpawnZ(lane) + -- For non-wall train spawning, check minimum distance + --print("Current Z: " .. currentZ .. ", Last Spawn Z: " .. tracker.lastSpawnZ .. ", Min Distance: " .. tracker.minDistance) return (currentZ - tracker.lastSpawnZ) >= tracker.minDistance end @@ -1491,12 +1487,14 @@ function clearSegments() end segments = {} -- Reset lane trackers + -- print("Resetting lane trackers in clearSegments()") laneTrackers.left.lastSpawnZ = 0 laneTrackers.center.lastSpawnZ = 0 laneTrackers.right.lastSpawnZ = 0 laneTrackers.left.minDistance = 100 laneTrackers.center.minDistance = 100 laneTrackers.right.minDistance = 100 + --printLaneTrackers() laneTrackers.left.wallTrainCount = 0 laneTrackers.center.wallTrainCount = 0 laneTrackers.right.wallTrainCount = 0 @@ -1748,14 +1746,25 @@ function updateTutorial() TUTORIAL_ENABLED = false OBSTACLE_SPAWN_Z_OFFSET = 0 print("Tutorial ended!") - local store = KeyValueStore(Player.UserID) - store:Set("tutorial_completed", true, function(success) - if success then - --print("Tutorial completed saved") - else - --print("Tutorial completed not saved") - end - end) + + -- Reset lane trackers after tutorial ends so normal spawning can begin + print("Resetting lane trackers after tutorial end") + laneTrackers.left.lastSpawnZ = 0 + laneTrackers.center.lastSpawnZ = 0 + laneTrackers.right.lastSpawnZ = 0 + laneTrackers.left.minDistance = 100 + laneTrackers.center.minDistance = 100 + laneTrackers.right.minDistance = 100 + printLaneTrackers() + + local store = KeyValueStore(Player.UserID) + store:Set("tutorial_completed", true, function(success) + if success then + --print("Tutorial completed saved") + else + --print("Tutorial completed not saved") + end + end) cleanupTutorialObstacles() end end @@ -1800,8 +1809,7 @@ Client.Tick = function(dt) end if currentState == STATES.MENU then - -- In menu state, just spawn initial segments - updateSegments(gameProgress) + -- In menu state, just update cliffs (no obstacle spawning) updateCliffs() return end @@ -1809,8 +1817,7 @@ Client.Tick = function(dt) if currentState == STATES.READY then -- Update UI in ready state updateScoreDisplay(score) - -- Spawn segments but don't update score or move obstacles - updateSegments(gameProgress) + -- Just update cliffs, don't spawn obstacles yet updateCliffs() return end @@ -1875,10 +1882,8 @@ Client.Tick = function(dt) -- Calculate offset based on position delta local dz = groundMotionTracker.Position.Z - (groundMotionLastZ or 0) groundMotionLastZ = groundMotionTracker.Position.Z - -- Use dz to update groundImage and yellow line offsets + -- Use dz to update groundImage groundImageMiddle.Offset.Y = groundImageMiddle.Offset.Y + dz * GROUND_MOTION_MULTIPLIER - -- groundImageRight.Offset.Y = groundImageRight.Offset.Y + dz * GROUND_MOTION_MULTIPLIER - -- groundImageLeft.Offset.Y = groundImageLeft.Offset.Y + dz * GROUND_MOTION_MULTIPLIER if isMoving then targetLane = math.max(-1, math.min(1, targetLane)) @@ -1889,11 +1894,4 @@ Client.Tick = function(dt) isMoving = false end end -end - - - - - - - +end \ No newline at end of file From 3953b9866c2ab3335ffaec7e6af61dd73e13dc09 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Wed, 16 Jul 2025 14:34:25 -0700 Subject: [PATCH 10/13] Better stair collisions, tree assets, sound toggle --- nickb30/game.lua | 370 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 281 insertions(+), 89 deletions(-) diff --git a/nickb30/game.lua b/nickb30/game.lua index ecacb23..7d4785e 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -17,6 +17,12 @@ Config.Items = { --Dev.DisplayColliders = true Config.ConstantAcceleration *= 2 +local displayedColliders = {} +function displayCollider(o) + table.insert(displayedColliders, o) + Dev.DisplayColliders = displayedColliders +end + -- CONSTANTS local GROUND_MOTION_MULTIPLIER = 4/384 -- NEEDS UPDATED VALUE local JUMP_STRENGTH = 150 @@ -37,7 +43,7 @@ local MAX_SPAWN_DISTANCE = 400 -- Maximum distance to spawn obstacles ahead local SPAWN_SPACING = 50 -- Spacing between spawn attempts local MAX_SPAWNS_PER_FRAME = 10 -- Maximum obstacles to spawn per frame local CLEANUP_DISTANCE = 80 -- Distance behind player to clean up obstacles -local STAIRS_BOOST_MULTIPLIER = 3.0 -- Multiplier for stairs boost (increased for high speeds) +local STAIRS_BOOST_MULTIPLIER = 1.3 -- Multiplier for stairs boost (increased for high speeds) local LANE_MOVEMENT_SPEED = 1000 -- Speed multiplier for lane movement local LANE_MOVEMENT_THRESHOLD = 0.01 -- Threshold for lane movement completion @@ -65,6 +71,7 @@ local COLLISION_GROUPS = { COLLIDERS = CollisionGroups(3), COLLECTIBLES = CollisionGroups(4), PLAYER = CollisionGroups(5), + SLOPE = CollisionGroups(6), } -- Lane-based obstacle spawning system @@ -129,9 +136,13 @@ local newHighScoreText = nil local newHighScorePanel = nil local currentState = STATES.LOADING local assetsLoaded = 0 -local totalAssets = 6 -- log, wall, flag, stairs, cliff, tutorial_completed +local totalAssets = 7 -- log, wall, flag, stairs, cliff, tutorial_completed, tree local startButton = nil local restartButton = nil +local musicButtonOn = nil +local musicButtonOff = nil +local gamesPlayed = 0 +local soundOn = true -- Tutorial state variables local tutorialState = 0 -- 0 = not started, 1 = walls, 2 = logs, 3 = flags, 4 = complete @@ -151,11 +162,9 @@ local FLASH_INTERVAL = 0.1 -- Time between flash toggles in seconds local isFlashing = false local showLeaderboardTimer = 0 -- Timer for showing leaderboard after game over --- Track last ground collider for footsteps -local lastGroundCollider = nil - -- Track last ground obstacle type for footsteps local lastGroundObstacleType = nil +local isOnStairs = false local OBSTACLE_SPAWN_Z_OFFSET = 850 @@ -295,6 +304,14 @@ function dropPlayer() if leaderboardUI then leaderboardUI:show() end if startButton then startButton:show() end if restartButton then restartButton:hide() end + -- Don't reset music button state - preserve current sound setting + if soundOn then + if musicButtonOn then musicButtonOn:show() end + if musicButtonOff then musicButtonOff:hide() end + else + if musicButtonOn then musicButtonOn:hide() end + if musicButtonOff then musicButtonOff:show() end + end end function gameOver() @@ -303,8 +320,14 @@ function gameOver() -- Reload the leaderboard UI after the score is submitted leaderboardUI:reload() end}) + -- update games_played + local store = KeyValueStore(Player.UserID) + store:Set("games_played", gamesPlayed + 1, function(success) end) + gamesPlayed += 1 isGameOver = true - sfx("death_scream_guy_4", { Volume = 0.5, Pitch = math.random() * 0.5 + 0.8, Spatialized = false }) + if soundOn then + sfx("death_scream_guy_4", { Volume = 0.5, Pitch = math.random() * 0.5 + 0.8, Spatialized = false }) + end print("Game Over") currentState = STATES.GAME_OVER Player.Animations.Walk:Stop() @@ -467,10 +490,12 @@ function checkForObstacleCollisions() Player.Position.Y = 0 -- Play collision sound based on obstacle type - if obstacleType == "log" then - sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) - else - sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + if soundOn then + if obstacleType == "log" then + sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + else + sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + end end gameOver() @@ -496,11 +521,15 @@ else if x == 1 then targetLane += 1 isMoving = true - sfx("whooshes_small_1", { Volume = 0.5, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + if soundOn then + sfx("whooshes_small_1", { Volume = 0.4, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + end elseif x == -1 then targetLane -= 1 isMoving = true - sfx("whooshes_small_1", { Volume = 0.5, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + if soundOn then + sfx("whooshes_small_1", { Volume = 0.4, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + end end if y == 1 then if Player.IsOnGround then @@ -543,12 +572,14 @@ function updateFootsteps(dt) -- Play footstep sound at regular intervals if footstepTimer >= FOOTSTEP_INTERVAL then - if Player.Position.Y > 41 then - -- Player is on top of a wall - use concrete sound for walking - sfx("walk_concrete_1", { Volume = 0.3, Pitch = 2.3 + math.random() * 0.2, Spatialized = false }) - else - -- Player is on ground - use grass sound for walking - sfx("walk_grass_1", { Volume = 0.4, Pitch = 2.3 + math.random() * 0.2, Spatialized = false }) + if soundOn then + if Player.Position.Y > 41 then + -- Player is on top of a wall - use concrete sound for walking + sfx("walk_concrete_1", { Volume = 0.2, Pitch = 2.3 + math.random() * 0.2, Spatialized = false }) + else + -- Player is on ground - use grass sound for walking + sfx("walk_grass_1", { Volume = 0.4, Pitch = 2.3 + math.random() * 0.4, Spatialized = false }) + end end footstepTimer = footstepTimer % FOOTSTEP_INTERVAL -- Use modulo instead of reset to 0 end @@ -591,17 +622,23 @@ Pointer.Drag = function(pe) local Ydiff = pos.Y - downPos.Y if swipeTriggered == false then - -- Swipe Right - if Xdiff > SWIPE_THRESHOLD and currentLane <= 0 then + -- Swipe Right / Left + if math.abs(Xdiff) > math.abs(Ydiff) then + if Xdiff > SWIPE_THRESHOLD and currentLane <= 0 then swipeTriggered = true targetLane += 1 isMoving = true - sfx("whooshes_small_1", { Volume = 0.5, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + if soundOn then + sfx("whooshes_small_1", { Volume = 0.4, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + end elseif Xdiff < -SWIPE_THRESHOLD and currentLane >= 0 then swipeTriggered = true targetLane -= 1 isMoving = true - sfx("whooshes_small_1", { Volume = 0.5, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + if soundOn then + sfx("whooshes_small_1", { Volume = 0.4, Pitch = math.random(9000, 10000) / 10000, Spatialized = false }) + end + end elseif Ydiff > SWIPE_THRESHOLD then swipeTriggered = true if Player.IsOnGround then @@ -629,23 +666,33 @@ Client.OnWorldObjectLoad = function(o) groundLevel = o.Position.Y + o.Height * o.Scale.Y o.CollisionGroups = COLLISION_GROUPS.GROUND o.CollidesWithGroups = COLLISION_GROUPS.PLAYER + ground = o end end -- function executed when the game starts Client.OnStart = function() + local BoxMax = Player.CollisionBox.Max + local BoxMin = Player.CollisionBox.Min + Player.CollisionBox = Box(BoxMin + Number3(0, 0, 3), BoxMax - Number3(0, 0, 3)) + Player.Body.LocalRotation.X = math.rad(90) + + + -- set tutorial completed to false for testing local store = KeyValueStore(Player.UserID) --store:Set("tutorial_completed", false, function(success) end) - store:Get("tutorial_completed", function(success, results) + store:Get("tutorial_completed", "games_played", function(success, results) if success then tutorialCompleted = results.tutorial_completed + gamesPlayed = results.games_played or 0 + print("Games played: " .. gamesPlayed) assetsLoaded += 1 if assetsLoaded == totalAssets then currentState = STATES.MENU end -- Move the tutorial completion check here, after everything is loaded - --print("Tutorial completed: " .. tostring(tutorialCompleted)) + print("Tutorial completed: " .. tostring(tutorialCompleted)) if tutorialCompleted then OBSTACLE_SPAWN_Z_OFFSET = 0 TUTORIAL_ENABLED = false @@ -775,13 +822,31 @@ Client.OnStart = function() trigger.Physics = PhysicsMode.Trigger local triggerBox = Box() triggerBox:Fit(wrapper, { recurse = true, localBox = true}) - triggerBox.Min += Number3(0, 0, -8) - triggerBox.Max += Number3(0, 3, 0) + boxSize = triggerBox.Size:Copy() + triggerBox.Min += Number3(0, 0, 0) + triggerBox.Max += Number3(0, 20, 10) trigger.CollisionBox = triggerBox wrapper:AddChild(trigger) trigger.CollisionGroups = nil trigger.CollidesWithGroups = COLLISION_GROUPS.PLAYER - + trigger.stairTrigger = true + --displayCollider(trigger) + + -- create a sloped trigger for the stairs + local slopeTrigger = Object() + slopeTrigger.Physics = PhysicsMode.Static + local diagonal = math.sqrt(boxSize.Z^2 + boxSize.Y^2) + local slopeTriggerBox = Box({-boxSize.X/2, 0, 0}, {boxSize.X/2, diagonal, 10}) + local theta = math.atan2(boxSize.Y, boxSize.Z) + slopeTrigger.Rotation = Number3(math.rad(90) - theta, 0, 0) + slopeTrigger.LocalPosition = Number3(0, 0, -12) + slopeTrigger.CollisionBox = slopeTriggerBox + wrapper:AddChild(slopeTrigger) + slopeTrigger.CollisionGroups = COLLISION_GROUPS.SLOPE + slopeTrigger.CollidesWithGroups = nil + --displayCollider(slopeTrigger) + + trigger.slope = slopeTrigger elseif type == "cliff" then -- set scale and rotation for cliff @@ -790,6 +855,14 @@ Client.OnStart = function() mesh.LocalRotation = fixedRotation mesh.Scale = scale wrapper.CollisionGroups = nil + elseif type == "tree" then + -- set scale and rotation for tree + local fixedRotation = Number3(0, 0, 0) + scale:Rotate(fixedRotation) + mesh.LocalRotation = fixedRotation + mesh.Scale = scale + wrapper.CollisionGroups = nil + wrapper.CollidesWithGroups = nil end wrapper:Recurse(function(o) @@ -865,8 +938,23 @@ Client.OnStart = function() end end) + -- load tree asset + -- gltf/tree-with-cube-leaves-1.glb + HTTP:Get("https://files.blip.game/gltf/tree-with-cube-leaves-1.glb", function(response) + if response.StatusCode == 200 then + local req = Object:Load(response.Body, function(o) + treePart = wrapMesh(o, Number3(10, 10, 10), "tree") + assetsLoaded = assetsLoaded + 1 + if assetsLoaded == totalAssets then + currentState = STATES.MENU + end + end) + end + end) + -- function to create cliff part texture -- edit this function to put the webquad into an object, and return the object + local cliffLoaded = false function createCliffPart() local cliffObj = Object() local quad = webquad:create({ @@ -886,7 +974,10 @@ Client.OnStart = function() cliffObj.Acceleration = -Config.ConstantAcceleration cliffObj.CollisionGroups = nil cliffObj.CollidesWithGroups = nil - assetsLoaded = assetsLoaded + 1 + if not cliffLoaded then + assetsLoaded = assetsLoaded + 1 + cliffLoaded = true + end if assetsLoaded == totalAssets then currentState = STATES.MENU end @@ -945,12 +1036,28 @@ Client.OnStart = function() collidesWithGroups = nil, -- camera will not go through objects in these groups } + --Player.OnCollision = function(self, other, normal) + -- print("Other: " , other) + + -- end + + Player.OnCollisionBegin = function(self, other, normal) + -- check if player is colliding with stairs + -- Check for landing sound when player hits ground from above if normal.Y > 0.5 and currentState == STATES.RUNNING then - local obstacleType = obstaclesByRef[other] + if other == ground then + obstacleType = "ground" + else + obstacleType = obstaclesByRef[other] + end if lastGroundObstacleType ~= obstacleType then - footstepTimer = FOOTSTEP_INTERVAL + --print("Obstacle type: " .. (obstacleType or "nil")) + --print("Last obstacle type: " .. (lastGroundObstacleType or "nil")) + Client:HapticFeedback() + footstepTimer = 0 + updateFootsteps(FOOTSTEP_INTERVAL) lastGroundObstacleType = obstacleType end end @@ -960,10 +1067,21 @@ Client.OnStart = function() -- Check if this is a stairs trigger local parent = other.Parent if obstaclesByRef[parent] == "stairs" then - -- Give the player a boost up and forward, scaled by current game speed - -- At higher speeds, we need a stronger boost to clear obstacles - local boostStrength = gameSpeed * STAIRS_BOOST_MULTIPLIER - Player.Motion.Y = boostStrength -- Upward boost + if normal.X == 0 then + isOnStairs = true + else + if normal.X < 0 then + targetLane -= 1 + -- hit block from the left + elseif normal.X > 0 then + targetLane += 1 + end + isSlowDownActive = true + slowDownTimer = SLOW_DOWN_DURATION + if soundOn then + sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + end + end return end other = parent @@ -984,10 +1102,12 @@ Client.OnStart = function() -- Check for front collision (always fatal) if normal.Z < 0 then -- Play collision sound based on obstacle type - if obstacleType == "log" then - sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) - else - sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + if soundOn then + if obstacleType == "log" then + sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + else + sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + end end gameOver() return @@ -996,10 +1116,12 @@ Client.OnStart = function() -- Check if player is already flashing and hits from side if isSlowDownActive and normal.Y == 0 then -- Play collision sound based on obstacle type - if obstacleType == "log" then - sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) - else - sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + if soundOn then + if obstacleType == "log" then + sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + else + sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + end end gameOver() return @@ -1017,25 +1139,25 @@ Client.OnStart = function() slowDownTimer = SLOW_DOWN_DURATION -- Play collision sound based on obstacle type - if obstacleType == "log" then - sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) - else - sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + if soundOn then + if obstacleType == "log" then + sfx("wood_impact_5", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + else + sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + end end end end Player.OnCollisionEnd = function(self, other, normal) - local obstacleType = obstaclesByRef[other] - if lastGroundObstacleType == obstacleType then - lastGroundObstacleType = nil - end + if other.Physics == PhysicsMode.Trigger or other.Physics == PhysicsMode.Static then if other.Parent ~= nil then -- Check if this is a stairs trigger local parent = other.Parent if obstaclesByRef[parent] == "stairs" then - Player.Motion.Y = 0 + --Player.Motion.Y = 0 + isOnStairs = false return end other = parent @@ -1068,6 +1190,31 @@ Client.OnStart = function() end restartButton:hide() + -- Create music buttons (volume and mute) + musicButtonOn = ui:buttonNeutral({content = "🔊"}) + musicButtonOn.Width = 50 + musicButtonOn.Height = 50 + musicButtonOn.pos = { Menu.Position.X, Menu.Position.Y - 70 } + musicButtonOn.onRelease = function() + print("music button pressed") + soundOn = false + musicButtonOn:hide() + musicButtonOff:show() + end + musicButtonOn:show() + + musicButtonOff = ui:buttonNeutral({content = "🔇"}) + musicButtonOff.Width = 50 + musicButtonOff.Height = 50 + musicButtonOff.pos = { Menu.Position.X, Menu.Position.Y - 70 } + musicButtonOff.onRelease = function() + print("music button pressed") + soundOn = true + musicButtonOff:hide() + musicButtonOn:show() + end + musicButtonOff:hide() + function spawnTreesOnCliff(cliff) -- Check if trees already exist by looking for tree children if cliff.hasTrees then @@ -1077,27 +1224,29 @@ Client.OnStart = function() cliff.hasTrees = true -- Place two trees at 1/3 and 2/3 along the local X axis of the cliff local positions = { - -CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.3, - -CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.7, + Number3(-CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.5, 16, 5), + Number3(-CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.5, 20, 7), + Number3(CLIFF_LENGTH/2 - CLIFF_LENGTH * 0.5, 12, 3), + --CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.3, + --CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.7, -- -CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.25, -- -CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.75 } - for _, x in ipairs(positions) do - local treeAsset = Config.Items[math.random(1, #Config.Items)] - local tree = Shape(treeAsset) - cliff:AddChild(tree) - tree.Name = "tree" -- Give trees a name for identification - tree.Pivot = {tree.Width * 0.5, 0, tree.Depth * 0.5} - tree.Scale = Number3(1, 1, 0.7) - tree.LocalPosition = Number3(x, 16, 5) - tree.CollisionGroups = nil - tree.CollidesWithGroups = nil - tree.Physics = PhysicsMode.Disabled - tree.Shadow = true - -- Counter-rotate the tree to stand upright despite cliff rotation - tree.LocalRotation = Number3(-math.pi/6, 0, 0) - end + -- pick a random position from the table + local randomPosition = positions[math.random(1, #positions)] + + print("Assets loaded: " .. assetsLoaded) + print("Total assets: " .. totalAssets) + local tree = treePart:Copy({ includeChildren = true }) + cliff:AddChild(tree) + tree.Name = "tree" -- Give trees a name for identification + tree.Scale = Number3(1, 1, 0.7) + tree.LocalPosition = randomPosition + tree.CollisionGroups = nil + tree.CollidesWithGroups = nil + tree.Physics = PhysicsMode.Disabled + tree.LocalRotation = Number3(-math.pi/6, 0, 0) end end @@ -1234,27 +1383,23 @@ end -- Restore getPooledObstacle for pooling function getPooledObstacle(obstacleType) local pool = obstaclePools[obstacleType] - if pool and #pool > 0 then - local obj = table.remove(pool) + local obj = table.remove(pool) + if obj ~= nil then obj.IsHidden = false - if obstacleType == "cliff" then - -- print("Spawned cliff from pool (recycled)") - end return obj - else - if obstacleType == "log" and logPart then - return logPart:Copy({ includeChildren = true }) - elseif obstacleType == "wall" and wallPart then - return wallPart:Copy({ includeChildren = true }) - elseif obstacleType == "flag" and flagPart then - return flagPart:Copy({ includeChildren = true }) - elseif obstacleType == "stairs" and stairsPart then - return stairsPart:Copy({ includeChildren = true }) - elseif obstacleType == "cliff" and cliffPart then - --print("Spawned new cliff (not recycled)") - return createCliffPart() - -- cliffPart:Copy({ includeChildren = true }) - end + end + -- if we get here, we need to spawn a new obstacle + --print("Spawned new copy: " .. obstacleType) + if obstacleType == "log" and logPart then + return logPart:Copy({ includeChildren = true }) + elseif obstacleType == "wall" and wallPart then + return wallPart:Copy({ includeChildren = true }) + elseif obstacleType == "flag" and flagPart then + return flagPart:Copy({ includeChildren = true }) + elseif obstacleType == "stairs" and stairsPart then + return stairsPart:Copy({ includeChildren = true }) + elseif obstacleType == "cliff" and cliffPart then + return createCliffPart() end return nil end @@ -1470,6 +1615,8 @@ function clearSegments() local type = obstaclesByRef[obstacle] if type and obstaclePools[type] then table.insert(obstaclePools[type], obstacle) + else + print("No pool for obstacle: " .. type) end obstaclesByRef[obstacle] = nil end @@ -1522,6 +1669,12 @@ end -- Add this function after updateCliffMotion or near other update functions function updateCliffs() + + if assetsLoaded < totalAssets then + --print("Assets not loaded yet") + return + end + local function getActiveCliffCountAndFurthestZ() local count = 0 local maxZ = 0 @@ -1545,8 +1698,8 @@ function updateCliffs() if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then --print("Attempting to spawn cliffs at Z: " .. spawnZ .. " (count: " .. count .. ")") end - local cliffRight = spawnObstacle("cliff", 1.8, spawnZ) - local cliffLeft = spawnObstacle("cliff", -1.8, spawnZ) + local cliffRight = spawnObstacle("cliff", 1.75, spawnZ) + local cliffLeft = spawnObstacle("cliff", -1.75, spawnZ) if cliffRight and cliffLeft then cliffRight.Rotation = Number3(math.pi/6, math.pi/2, 0) cliffLeft.Rotation = Number3(math.pi/6, -math.pi/2, 0) @@ -1790,6 +1943,45 @@ Client.Tick = function(dt) return end + if not Player.IsOnGround and lastGroundObstacleType ~= nil then + lastGroundObstacleType = nil + -- print("Resetting last ground obstacle type") + end + + if isOnStairs then + --[[ + local b = Player.CollisionBox + print("b.Min: ", b.Min) + + local r = Ray(Player.Position + Number3(0, 100, 0), Number3(0, -1, 0)) + local hit = r:Cast(COLLISION_GROUPS.SLOPE) + if hit == nil then + r.Origin.Z -= 4 + hit = r:Cast(COLLISION_GROUPS.SLOPE) + end + + if hit then + print("Hit slope") + stairsPosY = r.Origin.Y + hit.Distance * r.Direction.Y + 5 + if Player.Position.Y < stairsPosY then + Player.Position.Y = stairsPosY + end + end + ]] + + local b = Player.CollisionBox + local offset = Number3(0, 100, 0) + local wMax = Player:PositionLocalToWorld(b.Max) + offset + local wMin = Player:PositionLocalToWorld(b.Min) + offset - Number3(0, 0, 5) + local worldBox = Box(wMin, wMax) + local hit = worldBox:Cast(Number3(0, -1, 0), nil,COLLISION_GROUPS.SLOPE) + if hit then + stairsPosY = wMin.Y + hit.Distance * -1 + 1 + if Player.Position.Y < stairsPosY then + Player.Position.Y = stairsPosY + end + end + end -- Animate obstacles rising from below ground for obstacle, anim in pairs(obstacleAnimations) do if obstacle and obstacle.Parent then From 4c8b0e8b8608002a3b7244d40cf276d1bde39a71 Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Thu, 17 Jul 2025 11:52:43 -0700 Subject: [PATCH 11/13] Music and camera shake --- nickb30/game.lua | 125 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 86 insertions(+), 39 deletions(-) diff --git a/nickb30/game.lua b/nickb30/game.lua index 7d4785e..4ee3115 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -139,10 +139,13 @@ local assetsLoaded = 0 local totalAssets = 7 -- log, wall, flag, stairs, cliff, tutorial_completed, tree local startButton = nil local restartButton = nil -local musicButtonOn = nil -local musicButtonOff = nil +local soundOnButton = nil +local soundOffButton = nil +local musicOnButton = nil +local musicOffButton = nil local gamesPlayed = 0 local soundOn = true +local musicOn = true -- Tutorial state variables local tutorialState = 0 -- 0 = not started, 1 = walls, 2 = logs, 3 = flags, 4 = complete @@ -306,11 +309,19 @@ function dropPlayer() if restartButton then restartButton:hide() end -- Don't reset music button state - preserve current sound setting if soundOn then - if musicButtonOn then musicButtonOn:show() end - if musicButtonOff then musicButtonOff:hide() end + if soundOnButton then soundOnButton:show() end + if soundOffButton then soundOffButton:hide() end else - if musicButtonOn then musicButtonOn:hide() end - if musicButtonOff then musicButtonOff:show() end + if soundOnButton then soundOnButton:hide() end + if soundOffButton then soundOffButton:show() end + end + -- Music button state + if musicOn then + if musicOnButton then musicOnButton:show() end + if musicOffButton then musicOffButton:hide() end + else + if musicOnButton then musicOnButton:hide() end + if musicOffButton then musicOffButton:show() end end end @@ -354,7 +365,7 @@ function gameOver() end end -- Show leaderboard UI and restart button after a delay - showLeaderboardTimer = 2 + showLeaderboardTimer = 1 -- Hide the score text in top-right if scoreText then scoreText.IsHidden = true @@ -1070,16 +1081,31 @@ Client.OnStart = function() if normal.X == 0 then isOnStairs = true else - if normal.X < 0 then - targetLane -= 1 - -- hit block from the left - elseif normal.X > 0 then - targetLane += 1 - end - isSlowDownActive = true - slowDownTimer = SLOW_DOWN_DURATION - if soundOn then - sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + -- cast a ray from the player to the stairs to see if it hits the slope trigger + local b = Player.CollisionBox + local offset = Number3(0, 0, -4) + local wMax = Player:PositionLocalToWorld(b.Max) + offset + local wMin = Player:PositionLocalToWorld(b.Min) + offset + local worldBox = Box(wMin, wMax) + local hit = worldBox:Cast(Number3(-normal.X, 0, 0), nil, COLLISION_GROUPS.SLOPE) + -- local ray = Ray(Player.Position + Number3(0, 5, 0), Number3(-normal.X, 0, 0)) + -- local hit = ray:Cast(COLLISION_GROUPS.SLOPE) + if hit then + if hit.Distance < 35 then + if normal.X < 0 then + targetLane -= 1 + -- hit block from the left + elseif normal.X > 0 then + targetLane += 1 + end + isSlowDownActive = true + slowDownTimer = SLOW_DOWN_DURATION + if soundOn then + sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) + end + end + else + isOnStairs = true end end return @@ -1190,30 +1216,51 @@ Client.OnStart = function() end restartButton:hide() - -- Create music buttons (volume and mute) - musicButtonOn = ui:buttonNeutral({content = "🔊"}) - musicButtonOn.Width = 50 - musicButtonOn.Height = 50 - musicButtonOn.pos = { Menu.Position.X, Menu.Position.Y - 70 } - musicButtonOn.onRelease = function() - print("music button pressed") + -- Create sound buttons (volume and mute) + soundOnButton = ui:buttonNeutral({content = "🔊"}) + soundOnButton.Width = 50 + soundOnButton.Height = 50 + soundOnButton.pos = { Menu.Position.X, Menu.Position.Y - 70 } + soundOnButton.onRelease = function() soundOn = false - musicButtonOn:hide() - musicButtonOff:show() + soundOnButton:hide() + soundOffButton:show() end - musicButtonOn:show() + soundOnButton:show() - musicButtonOff = ui:buttonNeutral({content = "🔇"}) - musicButtonOff.Width = 50 - musicButtonOff.Height = 50 - musicButtonOff.pos = { Menu.Position.X, Menu.Position.Y - 70 } - musicButtonOff.onRelease = function() - print("music button pressed") + soundOffButton = ui:buttonNeutral({content = "🔇"}) + soundOffButton.Width = 50 + soundOffButton.Height = 50 + soundOffButton.pos = { Menu.Position.X, Menu.Position.Y - 70 } + soundOffButton.onRelease = function() soundOn = true - musicButtonOff:hide() - musicButtonOn:show() - end - musicButtonOff:hide() + soundOffButton:hide() + soundOnButton:show() + end + soundOffButton:hide() + + -- Create music buttons (music on and off) + musicOnButton = ui:buttonNeutral({content = "🎵"}) + musicOnButton.Width = 50 + musicOnButton.Height = 50 + musicOnButton.pos = { Menu.Position.X + 60, Menu.Position.Y - 70 } + musicOnButton.onRelease = function() + musicOn = false + musicOnButton:hide() + musicOffButton:show() + end + musicOnButton:show() + + musicOffButton = ui:buttonNeutral({content = "🔕"}) + musicOffButton.Width = 50 + musicOffButton.Height = 50 + musicOffButton.pos = { Menu.Position.X + 60, Menu.Position.Y - 70 } + musicOffButton.onRelease = function() + musicOn = true + musicOffButton:hide() + musicOnButton:show() + end + musicOffButton:hide() function spawnTreesOnCliff(cliff) -- Check if trees already exist by looking for tree children @@ -1236,8 +1283,8 @@ Client.OnStart = function() -- pick a random position from the table local randomPosition = positions[math.random(1, #positions)] - print("Assets loaded: " .. assetsLoaded) - print("Total assets: " .. totalAssets) + --print("Assets loaded: " .. assetsLoaded) + --print("Total assets: " .. totalAssets) local tree = treePart:Copy({ includeChildren = true }) cliff:AddChild(tree) tree.Name = "tree" -- Give trees a name for identification From 7cafa61c701e0f37a47b7dff7452c4e5fe0cd83e Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Thu, 17 Jul 2025 11:54:12 -0700 Subject: [PATCH 12/13] Camera shake --- nickb30/game.lua | 66 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/nickb30/game.lua b/nickb30/game.lua index 4ee3115..9527f6e 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -5,6 +5,7 @@ Modules = { ui = "uikit", webquad = "github.com/aduermael/modzh/webquad:cc6dda1", niceleaderboard = "github.com/aduermael/modzh/niceleaderboard:47c44c8", + music = "github.com/aduermael/modzh/music:786f55e" } Config.Items = { @@ -190,9 +191,9 @@ local function createTopRightScore() ) scoreText:setParent(node) node.parentDidResize = function() - node.pos = {Screen.Width - 55 - node.Width, Screen.Height - 55 - node.Height} - node.size = {scoreText.Width + 12, scoreText.Height + 10} - scoreText.pos = {5, 5} + node.pos = {Screen.Width - Screen.SafeArea.Right - node.Width - 20, Menu.Position.Y + 2} + node.size = {scoreText.Width + 12, Menu.Height - 1} + scoreText.pos = {5, (Menu.Height - scoreText.Height) / 2} end node:parentDidResize() end @@ -391,6 +392,16 @@ end function restartGame() print("Restarting game...") + Camera.Behavior = { + positionTarget = Player, -- camera goes to that position (or position of given object) + positionTargetOffset = { 0, 25, 0 }, -- applying offset to the target position (increased Y offset) + positionTargetBackoffDistance = 60, -- camera then tries to backoff that distance, considering collision (increased from 40) + positionTargetMinBackoffDistance = 30, -- minimum backoff distance (increased from 20) + positionTargetMaxBackoffDistance = 120, -- maximum backoff distance (increased from 100) + rotationTarget = Rotation(math.rad(20), 0, 0), -- camera rotates to that rotation (or rotation of given object) + rigidity = 0.3, -- how fast the camera moves to the target (reduced for smoother movement) + collidesWithGroups = nil, -- camera will not go through objects in these groups + } currentState = STATES.RUNNING isGameOver = false dropPlayer() @@ -684,6 +695,42 @@ end -- function executed when the game starts Client.OnStart = function() + -- camera shake + local shakeIntensity = 1.0 + local shakeTimer = 0 + local cameraStartShakePosition + function shakeCamera() + shakeTimer = 0.3 + cameraStartShakePosition = Camera.Position:Copy() + end + function applyCameraShake(dt) + Camera.Behavior = nil + if shakeTimer <= 0 then + Camera.Behavior = { + positionTarget = Player, -- camera goes to that position (or position of given object) + positionTargetOffset = { 0, 25, 0 }, -- applying offset to the target position (increased Y offset) + positionTargetBackoffDistance = 60, -- camera then tries to backoff that distance, considering collision (increased from 40) + positionTargetMinBackoffDistance = 30, -- minimum backoff distance (increased from 20) + positionTargetMaxBackoffDistance = 120, -- maximum backoff distance (increased from 100) + rotationTarget = Rotation(math.rad(20), 0, 0), -- camera rotates to that rotation (or rotation of given object) + rigidity = 0.3, -- how fast the camera moves to the target (reduced for smoother movement) + collidesWithGroups = nil, -- camera will not go through objects in these groups + } + return + end + shakeTimer = shakeTimer - dt + if shakeTimer > 0 then + Camera.Position.X = cameraStartShakePosition.X + (math.random() - 0.5) * 2 * shakeIntensity + Camera.Position.Y = cameraStartShakePosition.Y + (math.random() - 0.5) * 2 * shakeIntensity + else + Camera.Position:Set(cameraStartShakePosition) + end + end + + if musicOn then + music:play({ track = "run-for-fun", volume = 0.27}) + end + local BoxMax = Player.CollisionBox.Max local BoxMin = Player.CollisionBox.Min Player.CollisionBox = Box(BoxMin + Number3(0, 0, 3), BoxMax - Number3(0, 0, 3)) @@ -1098,6 +1145,7 @@ Client.OnStart = function() elseif normal.X > 0 then targetLane += 1 end + shakeCamera() isSlowDownActive = true slowDownTimer = SLOW_DOWN_DURATION if soundOn then @@ -1135,6 +1183,7 @@ Client.OnStart = function() sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) end end + shakeCamera() gameOver() return end @@ -1149,6 +1198,7 @@ Client.OnStart = function() sfx("metal_clanging_6", { Volume = 0.6, Pitch = math.random(9000, 11000) / 10000, Spatialized = false }) end end + shakeCamera() gameOver() return end @@ -1163,7 +1213,7 @@ Client.OnStart = function() end isSlowDownActive = true slowDownTimer = SLOW_DOWN_DURATION - + shakeCamera() -- Play collision sound based on obstacle type if soundOn then if obstacleType == "log" then @@ -1243,9 +1293,10 @@ Client.OnStart = function() musicOnButton = ui:buttonNeutral({content = "🎵"}) musicOnButton.Width = 50 musicOnButton.Height = 50 - musicOnButton.pos = { Menu.Position.X + 60, Menu.Position.Y - 70 } + musicOnButton.pos = { Menu.Position.X, Menu.Position.Y - 140 } musicOnButton.onRelease = function() musicOn = false + music:stop() musicOnButton:hide() musicOffButton:show() end @@ -1254,9 +1305,10 @@ Client.OnStart = function() musicOffButton = ui:buttonNeutral({content = "🔕"}) musicOffButton.Width = 50 musicOffButton.Height = 50 - musicOffButton.pos = { Menu.Position.X + 60, Menu.Position.Y - 70 } + musicOffButton.pos = { Menu.Position.X, Menu.Position.Y - 140 } musicOffButton.onRelease = function() musicOn = true + music:play() musicOffButton:hide() musicOnButton:show() end @@ -1995,6 +2047,8 @@ Client.Tick = function(dt) -- print("Resetting last ground obstacle type") end + applyCameraShake(dt) + if isOnStairs then --[[ local b = Player.CollisionBox From 411bbb1e07349090b5b19aee3de8e640e83eeb4b Mon Sep 17 00:00:00 2001 From: Nicolas Beringer Date: Fri, 18 Jul 2025 14:02:25 -0700 Subject: [PATCH 13/13] Badges, updated cliffs --- nickb30/game.lua | 264 ++++++++++++++++++++++++----------------------- 1 file changed, 134 insertions(+), 130 deletions(-) diff --git a/nickb30/game.lua b/nickb30/game.lua index 9527f6e..fb103c8 100644 --- a/nickb30/game.lua +++ b/nickb30/game.lua @@ -15,6 +15,11 @@ Config.Items = { "cawa2un.tree04", } +local badge = nil +if Client.BuildNumber >= 230 then + badge = require("badge") +end + --Dev.DisplayColliders = true Config.ConstantAcceleration *= 2 @@ -96,7 +101,6 @@ local obstacleTypes = { { type = "log", probability = 0.4, minDistance = 80 }, { type = "wall", probability = 0.25, minDistance = 120, trainLength = {1, 5} }, { type = "flag", probability = 0.2, minDistance = 100 }, - --{ type = "stairs", probability = 0.15, minDistance = 120 } } -- obstacle parts @@ -177,6 +181,18 @@ local obstacleAnimations = {} -- { [obstacle] = { targetY = number, duration = local OBSTACLE_SPAWN_ANIMATION_OFFSET = 20 -- How far below ground to start local OBSTACLE_SPAWN_ANIMATION_DURATION = 0.3 -- Animation duration in seconds +-- Badge unlock tracking +local previousScore = 0 +local badgeThresholds = { + { score = 1000, name = "good" }, + { score = 2000, name = "great" }, + { score = 5000, name = "amazing" }, + { score = 10000, name = "spectacular" }, + { score = 20000, name = "epic" }, + { score = 40000, name = "godlike" }, +} +local unlockedBadges = {} + local function createTopRightScore() -- Create score text in top-right corner node = ui:frameTextBackground() @@ -258,6 +274,13 @@ local CLIFF_LENGTH = 85 local CLIFF_SPAWN_INTERVAL = CLIFF_LENGTH - 15 -- match your cliff Z scale local nextCliffSpawnZ = 0 +local debug = false +function log(message) + if debug then + print(message) + end +end + -- In dropPlayer, reset nextCliffSpawnZ to the player's Z position function dropPlayer() Player.Position:Set(0, 40, 0) @@ -324,6 +347,9 @@ function dropPlayer() if musicOnButton then musicOnButton:hide() end if musicOffButton then musicOffButton:show() end end + + previousScore = 0 + unlockedBadges = {} end function gameOver() @@ -332,15 +358,11 @@ function gameOver() -- Reload the leaderboard UI after the score is submitted leaderboardUI:reload() end}) - -- update games_played - local store = KeyValueStore(Player.UserID) - store:Set("games_played", gamesPlayed + 1, function(success) end) - gamesPlayed += 1 isGameOver = true if soundOn then sfx("death_scream_guy_4", { Volume = 0.5, Pitch = math.random() * 0.5 + 0.8, Spatialized = false }) end - print("Game Over") + --print("Game Over") currentState = STATES.GAME_OVER Player.Animations.Walk:Stop() Player.Velocity = Number3(0, 0, 0) @@ -391,7 +413,6 @@ function gameOver() end function restartGame() - print("Restarting game...") Camera.Behavior = { positionTarget = Player, -- camera goes to that position (or position of given object) positionTargetOffset = { 0, 25, 0 }, -- applying offset to the target position (increased Y offset) @@ -409,8 +430,33 @@ function restartGame() end function startGame() - print("Starting game...") - print("spawn offset: " .. OBSTACLE_SPAWN_Z_OFFSET) + + -- update games_played + local store = KeyValueStore(Player.UserID) + store:Set("games_played", gamesPlayed + 1, function(success) end) + gamesPlayed += 1 + log("gamesPlayed: " .. gamesPlayed) + + -- gamesplayed badge unlocked based on gamesPlayed + -- "rookie" : 10 games + -- "resilient" : 100 games + -- "relentless" : 200 games + -- "unstoppable" : 500 games + if Client.BuildNumber >= 230 and badge then + if gamesPlayed >= 10 then + badge:unlockBadge("rookie", function(err) if err == nil then log("Rookie badge unlocked") end end) + end + if gamesPlayed >= 100 then + badge:unlockBadge("resilient", function(err) if err == nil then log("Resilient badge unlocked") end end) + end + if gamesPlayed >= 200 then + badge:unlockBadge("relentless", function(err) if err == nil then log("Relentless badge unlocked") end end) + end + if gamesPlayed >= 500 then + badge:unlockBadge("unstoppable", function(err) if err == nil then log("Unstoppable badge unlocked") end end) + end + end + currentState = STATES.RUNNING leaderboardUI:hide() Player.Animations.Walk:Play() @@ -442,7 +488,7 @@ end function startCrouch() if not isCrouching then - if Player.IsOnGround then + if Player.IsOnGround or isOnStairs then -- Player is on ground, crouch immediately isCrouching = true crouchTimer = CROUCH_DURATION @@ -468,7 +514,7 @@ end function updateCrouch(dt) -- Check if player wanted to crouch and just landed - if wantsToCrouch and Player.IsOnGround then + if wantsToCrouch and (Player.IsOnGround or isOnStairs) then wantsToCrouch = false isCrouching = true crouchTimer = CROUCH_DURATION @@ -584,7 +630,19 @@ end function updateScore(dt) -- Score increases based on game speed multiplier for higher difficulty = higher rewards local scoreMultiplier = difficultyMultiplier or 1.0 + previousScore = score score = score + (SCORE_PER_SECOND * scoreMultiplier * dt) + -- unlock badges only when crossing thresholds + if Client.BuildNumber >= 230 and badge then + for _, badgeInfo in ipairs(badgeThresholds) do + if not unlockedBadges[badgeInfo.name] and previousScore < badgeInfo.score and score >= badgeInfo.score then + badge:unlockBadge(badgeInfo.name, function(err) + if err == nil then log(badgeInfo.name .. " badge unlocked") end + end) + unlockedBadges[badgeInfo.name] = true + end + end + end end function updateFootsteps(dt) @@ -608,9 +666,6 @@ function updateFootsteps(dt) else -- Only reset timer when game is not running, not when player is in air if currentState ~= STATES.RUNNING then - if footstepTimer > 0 then - print("Footstep timer reset: currentState=" .. currentState .. ", IsOnGround=" .. tostring(Player.IsOnGround)) - end footstepTimer = 0 end end @@ -682,9 +737,6 @@ end Client.OnWorldObjectLoad = function(o) if o.Name == "ground" then o.IsHidden = true - -- print("ground height: " .. o.Position.Y) - -- print(o.Height) - -- print("pivot: " .. o.Pivot.Y) groundLevel = o.Position.Y + o.Height * o.Scale.Y o.CollisionGroups = COLLISION_GROUPS.GROUND o.CollidesWithGroups = COLLISION_GROUPS.PLAYER @@ -695,6 +747,11 @@ end -- function executed when the game starts Client.OnStart = function() + if Client.BuildNumber >= 230 then + badge:unlockBadge("welcome", function(err) + end) + end + -- camera shake local shakeIntensity = 1.0 local shakeTimer = 0 @@ -744,20 +801,15 @@ Client.OnStart = function() if success then tutorialCompleted = results.tutorial_completed gamesPlayed = results.games_played or 0 - print("Games played: " .. gamesPlayed) + --print("Games played: " .. gamesPlayed) assetsLoaded += 1 if assetsLoaded == totalAssets then currentState = STATES.MENU end - -- Move the tutorial completion check here, after everything is loaded - print("Tutorial completed: " .. tostring(tutorialCompleted)) if tutorialCompleted then OBSTACLE_SPAWN_Z_OFFSET = 0 TUTORIAL_ENABLED = false - --print("Tutorial disabled due to previous completion") end - --print("OBSTACLE_SPAWN_Z_OFFSET: " .. OBSTACLE_SPAWN_Z_OFFSET) - --print("KeyValueStore: " .. tostring(results.tutorial_completed)) end end) @@ -773,10 +825,10 @@ Client.OnStart = function() Sky.AbyssColor = Color.White end end) - -- Collision Groups + -- Leaderboard - leaderboard = Leaderboard("default") - leaderboardUI = niceleaderboard({}) + leaderboard = Leaderboard("default_2") + leaderboardUI = niceleaderboard({leaderboardName = "default_2"}) leaderboardUI.Width = 200 leaderboardUI.Height = 300 leaderboardUI.Position = { Screen.Width / 2 - leaderboardUI.Width / 2, Screen.Height / 2 - leaderboardUI.Height / 2 } @@ -833,7 +885,12 @@ Client.OnStart = function() wrapper.CollidesWithGroups = COLLISION_GROUPS.PLAYER elseif type == "wall" then -- set scale and rotation - local fixedRotation = Number3(math.rad(90), 0, 0) + local fixedRotation + if Client.BuildNumber < 230 then + fixedRotation = Number3(math.rad(90), 0, 0) + else + fixedRotation = Number3(math.rad(90), 0, math.rad(180)) + end scale:Rotate(fixedRotation) mesh.LocalRotation = fixedRotation mesh.Scale = scale @@ -863,9 +920,14 @@ Client.OnStart = function() elseif type == "stairs" then -- set scale and rotation - local fixedRotation = Number3(0, 0, 0) - scale:Rotate(fixedRotation) - mesh.LocalRotation = fixedRotation + local fixedRotation + if Client.BuildNumber < 230 then + fixedRotation = Number3(0, 0, 0) + scale:Rotate(fixedRotation) + else + fixedRotation = Number3(0, math.rad(180), 0) + end + mesh.Rotation = fixedRotation mesh.Scale = scale -- set collision box and groups - make it a trigger for boost @@ -893,7 +955,7 @@ Client.OnStart = function() -- create a sloped trigger for the stairs local slopeTrigger = Object() slopeTrigger.Physics = PhysicsMode.Static - local diagonal = math.sqrt(boxSize.Z^2 + boxSize.Y^2) + local diagonal = math.sqrt(boxSize.Z^2 + boxSize.Y^2) local slopeTriggerBox = Box({-boxSize.X/2, 0, 0}, {boxSize.X/2, diagonal, 10}) local theta = math.atan2(boxSize.Y, boxSize.Z) slopeTrigger.Rotation = Number3(math.rad(90) - theta, 0, 0) @@ -1015,19 +1077,47 @@ Client.OnStart = function() local cliffLoaded = false function createCliffPart() local cliffObj = Object() + -- Main vertical face local quad = webquad:create({ url = "https://files.blip.game/textures/grass-tile.jpg", filtering = false, }) quad.Physics = PhysicsMode.Disabled - --quad.CollisionGroups = nil - --quad.CollidesWithGroups = nil quad.Width = CLIFF_LENGTH * 4 quad.Height = 45 * 4 quad.Scale = 1/4 quad.Anchor = { 0.5, 0 } - -- quad.Rotation = { math.rad(90), 0, 0} cliffObj:AddChild(quad) + + -- Top grass cap + local topQuad = webquad:create({ + url = "https://files.blip.game/textures/grass-tile.jpg", + filtering = false, + }) + topQuad.Physics = PhysicsMode.Disabled + topQuad.Width = CLIFF_LENGTH * 4 + topQuad.Height = 45 * 4 -- Use same as vertical for now + topQuad.Scale = 1/4 + topQuad.Anchor = { 0.5, 0 } -- Pivot from back edge + topQuad.Rotation = { 0, 0, 0 } -- Lay flat + -- 2*math.pi/6 to lay flat + topQuad.Position = { 0, 45, 0 } -- Y = height of vertical face (before scaling) + cliffObj:AddChild(topQuad) + + -- Top grass cap + local topQuad = webquad:create({ + url = "https://files.blip.game/textures/grass-tile.jpg", + filtering = false, + }) + topQuad.Physics = PhysicsMode.Disabled + topQuad.Width = CLIFF_LENGTH * 12 + topQuad.Height = 45 * 4 -- Use same as vertical for now + topQuad.Scale = 1/4 + topQuad.Anchor = { 0.5, 0 } -- Pivot from back edge + topQuad.Rotation = { 2*math.pi/6, 0, 0 } -- Lay flat + topQuad.Position = { 0, 90, 0 } -- Y = height of vertical face (before scaling) + cliffObj:AddChild(topQuad) + cliffObj.Physics = PhysicsMode.Dynamic cliffObj.Acceleration = -Config.ConstantAcceleration cliffObj.CollisionGroups = nil @@ -1072,13 +1162,8 @@ Client.OnStart = function() -- Load the high score initially loadHighScore() - -- Debug: Print initial lane tracker values - -- print("Initial lane tracker values:") - -- printLaneTrackers() - Player.Animations.Walk.Speed = ANIMATION_SPEED Player.Animations.Walk:Play() - -- print("Initial Player.Scale.Y:", Player.Scale.Y) Player.Scale.Y = NORMAL_SCALE -- Ensure player starts at normal scale World:AddChild(Player) dropPlayer() @@ -1094,12 +1179,6 @@ Client.OnStart = function() collidesWithGroups = nil, -- camera will not go through objects in these groups } - --Player.OnCollision = function(self, other, normal) - -- print("Other: " , other) - - -- end - - Player.OnCollisionBegin = function(self, other, normal) -- check if player is colliding with stairs @@ -1111,8 +1190,6 @@ Client.OnStart = function() obstacleType = obstaclesByRef[other] end if lastGroundObstacleType ~= obstacleType then - --print("Obstacle type: " .. (obstacleType or "nil")) - --print("Last obstacle type: " .. (lastGroundObstacleType or "nil")) Client:HapticFeedback() footstepTimer = 0 updateFootsteps(FOOTSTEP_INTERVAL) @@ -1127,6 +1204,9 @@ Client.OnStart = function() if obstaclesByRef[parent] == "stairs" then if normal.X == 0 then isOnStairs = true + if Player.Velocity.Y < 0 then + Player.Velocity.Y = 0 + end else -- cast a ray from the player to the stairs to see if it hits the slope trigger local b = Player.CollisionBox @@ -1135,8 +1215,6 @@ Client.OnStart = function() local wMin = Player:PositionLocalToWorld(b.Min) + offset local worldBox = Box(wMin, wMax) local hit = worldBox:Cast(Number3(-normal.X, 0, 0), nil, COLLISION_GROUPS.SLOPE) - -- local ray = Ray(Player.Position + Number3(0, 5, 0), Number3(-normal.X, 0, 0)) - -- local hit = ray:Cast(COLLISION_GROUPS.SLOPE) if hit then if hit.Distance < 35 then if normal.X < 0 then @@ -1326,17 +1404,11 @@ Client.OnStart = function() Number3(-CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.5, 16, 5), Number3(-CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.5, 20, 7), Number3(CLIFF_LENGTH/2 - CLIFF_LENGTH * 0.5, 12, 3), - --CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.3, - --CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.7, - -- -CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.25, - -- -CLIFF_LENGTH/2 + CLIFF_LENGTH * 0.75 } -- pick a random position from the table local randomPosition = positions[math.random(1, #positions)] - --print("Assets loaded: " .. assetsLoaded) - --print("Total assets: " .. totalAssets) local tree = treePart:Copy({ includeChildren = true }) cliff:AddChild(tree) tree.Name = "tree" -- Give trees a name for identification @@ -1369,15 +1441,7 @@ function updateSegments(gameProgress) end -- Do NOT return here; let normal cleanup logic run for flags and other obstacles end - - -- Reset lane trackers if lastSpawnZ is too far behind the current progress - --[[ - for _, tracker in pairs(laneTrackers) do - if gameProgress - tracker.lastSpawnZ > MAX_SPAWN_DISTANCE then - tracker.lastSpawnZ = gameProgress - MAX_SPAWN_DISTANCE + tracker.minDistance - end - end - ]] + -- get furthest Z position of cliffs local furthestCliffZ = 0 for obstacle, type in pairs(obstaclesByRef) do @@ -1390,7 +1454,6 @@ function updateSegments(gameProgress) currentSpawnZ = math.min(currentSpawnZ, furthestCliffZ + OBSTACLE_SPAWN_Z_OFFSET - 30) -- Spawn obstacles at the current position (no loop needed since this runs every frame) - --print("Attempting to spawn obstacles at Z: " .. currentSpawnZ) local newObstacles = spawnObstaclesAtPosition(currentSpawnZ) if newObstacles and #newObstacles > 0 then -- Create a segment entry for tracking @@ -1488,7 +1551,6 @@ function getPooledObstacle(obstacleType) return obj end -- if we get here, we need to spawn a new obstacle - --print("Spawned new copy: " .. obstacleType) if obstacleType == "log" and logPart then return logPart:Copy({ includeChildren = true }) elseif obstacleType == "wall" and wallPart then @@ -1535,7 +1597,6 @@ function spawnObstaclesAtPosition(zPosition) -- Lane obstacles for lane = -1, 1 do local tracker = getLaneTracker(lane) - --print("Can spawn in lane: " .. tostring(canSpawnInLane(lane, zPosition))) if tracker and canSpawnInLane(lane, zPosition) then local obstacleData = selectObstacleType() if wouldCreateImpossibleSegment(lane, obstacleData.type, zPosition) then @@ -1560,7 +1621,6 @@ function spawnObstaclesAtPosition(zPosition) end end tracker.lastSpawnZ = zPosition + ((trainLength) * WALL_SPACING) - --print("Set lastSpawnZ to: " .. tracker.lastSpawnZ) tracker.minDistance = obstacleData.minDistance tracker.wallTrainCount = 0 else @@ -1568,7 +1628,6 @@ function spawnObstaclesAtPosition(zPosition) if obstacle then table.insert(spawnedObstacles, obstacle) tracker.lastSpawnZ = zPosition - --print("Set lastSpawnZ to: " .. tracker.lastSpawnZ) tracker.minDistance = obstacleData.minDistance end end @@ -1613,14 +1672,12 @@ function prepopulateCliffPool(poolSize) print("Cannot prepopulate cliff pool - cliffPart not loaded yet") return end - - --print("Prepopulating cliff pool with " .. poolSize .. " cliffs...") + for i = 1, poolSize do local cliff = createCliffPart() cliff.IsHidden = true table.insert(obstaclePools.cliff, cliff) end - --print("Cliff pool prepopulated with " .. #obstaclePools.cliff .. " cliffs") end function getLaneTracker(lane) @@ -1669,7 +1726,6 @@ function canSpawnInLane(lane, currentZ) updateLaneTrackerLastSpawnZ(lane) -- For non-wall train spawning, check minimum distance - --print("Current Z: " .. currentZ .. ", Last Spawn Z: " .. tracker.lastSpawnZ .. ", Min Distance: " .. tracker.minDistance) return (currentZ - tracker.lastSpawnZ) >= tracker.minDistance end @@ -1733,14 +1789,12 @@ function clearSegments() end segments = {} -- Reset lane trackers - -- print("Resetting lane trackers in clearSegments()") laneTrackers.left.lastSpawnZ = 0 laneTrackers.center.lastSpawnZ = 0 laneTrackers.right.lastSpawnZ = 0 laneTrackers.left.minDistance = 100 laneTrackers.center.minDistance = 100 laneTrackers.right.minDistance = 100 - --printLaneTrackers() laneTrackers.left.wallTrainCount = 0 laneTrackers.center.wallTrainCount = 0 laneTrackers.right.wallTrainCount = 0 @@ -1756,21 +1810,14 @@ function updateCliffMotion(newSpeed) if type == "cliff" then obstacle.Motion.Z = -newSpeed cliffCount = cliffCount + 1 - if cliffCount == 1 then - -- print("Cliff position: " .. obstacle.Position.Z .. ", Motion.Z: " .. obstacle.Motion.Z) - end end end - if cliffCount > 0 then - --print("Updated " .. cliffCount .. " cliffs with speed: " .. newSpeed) - end end -- Add this function after updateCliffMotion or near other update functions function updateCliffs() if assetsLoaded < totalAssets then - --print("Assets not loaded yet") return end @@ -1789,14 +1836,8 @@ function updateCliffs() end local count, furthestZ = getActiveCliffCountAndFurthestZ() - if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then - --print("Tutorial cliffs - count: " .. count .. ", furthestZ: " .. furthestZ .. ", MAX_ACTIVE_CLIFFS: " .. MAX_ACTIVE_CLIFFS) - end while count < MAX_ACTIVE_CLIFFS do local spawnZ = (count == 0 and 0) or (furthestZ + CLIFF_SPAWN_INTERVAL) - if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then - --print("Attempting to spawn cliffs at Z: " .. spawnZ .. " (count: " .. count .. ")") - end local cliffRight = spawnObstacle("cliff", 1.75, spawnZ) local cliffLeft = spawnObstacle("cliff", -1.75, spawnZ) if cliffRight and cliffLeft then @@ -1813,13 +1854,7 @@ function updateCliffs() spawnTreesOnCliff(cliffLeft) count = count + 2 furthestZ = spawnZ - if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then - --print("Successfully spawned tutorial cliffs at Z: " .. spawnZ) - end else - if TUTORIAL_ENABLED and tutorialStarted and not tutorialCompleted then - --print("Failed to spawn cliffs at Z: " .. spawnZ) - end break end end @@ -1981,26 +2016,23 @@ function updateTutorial() break end end - --print("Tutorial State 3 - flagsPassed: " .. tostring(flagsPassed) .. ", allFlagsBehind: " .. tostring(allFlagsBehind) .. ", isCrouching: " .. tostring(isCrouching)) if isCrouching then if flagsPassed and allFlagsBehind then - --print("Tutorial completing - player crouched and flags passed!") tutorialState = 4 hideTutorialText() tutorialCompleted = true cleanupTutorialObstacles() end elseif flagsPassed and allFlagsBehind then - --print("Tutorial completing - flags passed without crouching!") tutorialState = 4 hideTutorialText() tutorialCompleted = true TUTORIAL_ENABLED = false OBSTACLE_SPAWN_Z_OFFSET = 0 - print("Tutorial ended!") + --print("Tutorial ended!") - -- Reset lane trackers after tutorial ends so normal spawning can begin - print("Resetting lane trackers after tutorial end") + -- Reset lane trackers after tutorial ends so normal spawning can begin + --print("Resetting lane trackers after tutorial end") laneTrackers.left.lastSpawnZ = 0 laneTrackers.center.lastSpawnZ = 0 laneTrackers.right.lastSpawnZ = 0 @@ -2010,13 +2042,7 @@ function updateTutorial() printLaneTrackers() local store = KeyValueStore(Player.UserID) - store:Set("tutorial_completed", true, function(success) - if success then - --print("Tutorial completed saved") - else - --print("Tutorial completed not saved") - end - end) + store:Set("tutorial_completed", true, function(success) end) cleanupTutorialObstacles() end end @@ -2044,32 +2070,11 @@ Client.Tick = function(dt) if not Player.IsOnGround and lastGroundObstacleType ~= nil then lastGroundObstacleType = nil - -- print("Resetting last ground obstacle type") end applyCameraShake(dt) if isOnStairs then - --[[ - local b = Player.CollisionBox - print("b.Min: ", b.Min) - - local r = Ray(Player.Position + Number3(0, 100, 0), Number3(0, -1, 0)) - local hit = r:Cast(COLLISION_GROUPS.SLOPE) - if hit == nil then - r.Origin.Z -= 4 - hit = r:Cast(COLLISION_GROUPS.SLOPE) - end - - if hit then - print("Hit slope") - stairsPosY = r.Origin.Y + hit.Distance * r.Direction.Y + 5 - if Player.Position.Y < stairsPosY then - Player.Position.Y = stairsPosY - end - end - ]] - local b = Player.CollisionBox local offset = Number3(0, 100, 0) local wMax = Player:PositionLocalToWorld(b.Max) + offset @@ -2077,7 +2082,7 @@ Client.Tick = function(dt) local worldBox = Box(wMin, wMax) local hit = worldBox:Cast(Number3(0, -1, 0), nil,COLLISION_GROUPS.SLOPE) if hit then - stairsPosY = wMin.Y + hit.Distance * -1 + 1 + stairsPosY = wMin.Y + hit.Distance * -1 + 2 if Player.Position.Y < stairsPosY then Player.Position.Y = stairsPosY end @@ -2169,7 +2174,6 @@ Client.Tick = function(dt) end -- Also update cliff motion during tutorial updateCliffMotion(gameSpeed) - --print("Tutorial active - updating cliff motion with speed: " .. gameSpeed) end -- Calculate offset based on position delta