diff --git a/Content/scripts/sonic_the_hedgehog/README.md b/Content/scripts/sonic_the_hedgehog/README.md new file mode 100644 index 0000000000..d10bcad538 --- /dev/null +++ b/Content/scripts/sonic_the_hedgehog/README.md @@ -0,0 +1,218 @@ +# Sonic The Hedgehog for WickedEngine + +A Lua script that implements a playable Sonic The Hedgehog character with physics accurate to Sonic The Hedgehog 2 (1992). + +## Features + +### Sonic 2 Accurate Physics + +This implementation faithfully recreates the physics system from Sonic The Hedgehog 2, including: + +#### Ground Movement +- **Acceleration**: 0.046875 pixels/frame² (12/256 subpixels) +- **Deceleration**: 0.5 pixels/frame² (128/256 subpixels) +- **Friction**: 0.046875 pixels/frame² (same as acceleration) +- **Top Speed**: 6 pixels/frame (1536/256 subpixels) + +#### Rolling Physics +- **Roll Friction**: 0.0234375 pixels/frame² (half of normal friction) +- **Roll Deceleration**: 0.125 pixels/frame² (when actively braking) +- Automatic uncurl when speed drops below 1 pixel/frame + +#### Air Movement +- **Air Acceleration**: 0.09375 pixels/frame² (2x ground acceleration) +- **Air Drag**: Applied when moving upward and horizontally fast +- No deceleration or friction while airborne + +#### Jumping +- **Jump Force**: 6.5 pixels/frame initial velocity +- **Variable Jump Height**: Release early for shorter jumps +- **Jump Release Cap**: 4 pixels/frame minimum upward velocity + +#### Gravity +- **Gravity**: 0.21875 pixels/frame² (56/256 subpixels) +- **Terminal Velocity**: 16 pixels/frame + +#### Slope Physics +- **Slope Factor**: 0.125 pixels/frame² (affects ground speed on inclines) +- **Roll Uphill Factor**: 0.078125 pixels/frame² (less speed loss going up) +- **Roll Downhill Factor**: 0.3125 pixels/frame² (more speed gain going down) + +#### Spin Dash (Sonic 2 Feature) +- **Base Speed**: 8 pixels/frame +- **Charge Increment**: 2 pixels/frame per button press +- **Maximum Charges**: 8 +- **Charge Decay**: floor(charge × 8) / 256 per frame + +### Character States + +The script implements all classic Sonic states: +- Idle +- Walking +- Running +- Jumping +- Rolling (Spin Attack) +- Spin Dash +- Skidding +- Pushing +- Looking Up +- Crouching +- Balancing (edge detection) +- Hurt +- Dying + +### Sprite Sheet + +The script uses the authentic Sonic 2 sprite sheet located at `assets/sonic_sprites.png` (10073.png from The Spriters Resource). The sprite sheet includes: +- Idle and standing poses +- Walking animation (8 frames) +- Running animation (4 frames with motion blur legs) +- Rolling/Spin ball animation (5 frames) +- Skidding/braking pose +- Pushing animation (4 frames) +- Looking up and crouching poses +- Edge balancing animation (2 frames) +- Hurt and dying poses + +### Collision System + +The script uses a sensor-based collision system similar to the original: +- Ground sensors for floor detection +- Wall sensors for horizontal collision +- Ceiling sensors for overhead collision +- Slope angle detection for proper physics on inclines + +## Controls + +| Action | Keyboard | Gamepad | +|--------|----------|---------| +| Move Left/Right | Arrow Keys / A,D | Left Stick | +| Look Up | Up Arrow / W | Left Stick Up | +| Crouch | Down Arrow / S | Left Stick Down | +| Jump | Shift | A Button | +| Roll | Down while moving | Down while moving | +| Spin Dash | Down + Shift (hold & release) | Down + A (hold & release) | +| Toggle Debug | H | - | +| Reload Script | R | - | +| Exit | Escape | - | + +## Usage + +### Standalone Demo + +Run the script directly to see Sonic in action with a demo level: + +```lua +dofile("Content/scripts/sonic_the_hedgehog/sonic_the_hedgehog.lua") +``` + +### Importing into Your Own Scene + +The script exports a `SonicModule` that can be used to add Sonic to any existing scene: + +```lua +-- Load the module +local sonic_script = dofile("Content/scripts/sonic_the_hedgehog/sonic_the_hedgehog.lua") + +-- Or access the global module after loading +local SonicModule = _G.SonicModule + +-- Create Sonic for your render path +local sonic, hud = SonicModule.CreateSonicForPath(myRenderPath, startX, startY, true) + +-- In your game loop: +sonic:Update(dt) +sonic:Render(myRenderPath) +hud:Update(dt, sonic) + +-- Customize physics if needed +SonicModule.Physics.JUMP_FORCE = SonicModule.Physics.JUMP_FORCE * 1.5 -- Higher jumps + +-- Set custom ground level +SonicModule.SetGroundLevel(500) + +-- Set custom screen bounds +SonicModule.SetScreenBounds(1920, 1080, 64) +``` + +### Module API Reference + +- `SonicModule.Sonic` - The Sonic character class +- `SonicModule.HUD` - The HUD class +- `SonicModule.Physics` - Physics constants (modifiable) +- `SonicModule.SonicState` - Character state constants +- `SonicModule.LevelConfig` - Level configuration +- `SonicModule.CreateSonicForPath(path, x, y, includeHUD)` - Quick setup helper +- `SonicModule.SetGroundLevel(y)` - Set the ground collision level +- `SonicModule.SetScreenBounds(width, height, margin)` - Set screen boundaries + +### Integration with 3D Scenes + +The script creates a 2D render path by default. To integrate with a 3D scene: + +1. Use `SonicModule.CreateSonicForPath()` with your existing RenderPath3D +2. Add scene collision detection using `scene.Intersects()` +3. Load your level geometry with navigation mesh tags + +### Customization + +#### Adjusting Physics + +All physics constants are defined in the `Physics` table at the top of the script. You can modify these to create different gameplay feels: + +```lua +local Physics = SonicModule.Physics +Physics.TOP_SPEED = Physics.TOP_SPEED * 1.5 -- Faster top speed +Physics.JUMP_FORCE = Physics.JUMP_FORCE * 1.2 -- Higher jumps +Physics.GRAVITY = Physics.GRAVITY * 0.8 -- Floatier physics +``` + +#### Adding Sprites + +The script uses the authentic Sonic 2 sprite sheet. Frame definitions are in `SonicModule.SpriteFrames`: + +```lua +-- Customize animation frames +SonicModule.SpriteFrames.idle = {{x, y, width, height}} +``` + +## Technical Notes + +### Subpixel Conversion + +The original Sonic games used a 256 subpixel-per-pixel system at 60 FPS. This script converts these values to pixels/second for WickedEngine's variable timestep: + +```lua +-- Convert from subpixels/frame to pixels/second +local function toPixelsPerSecond(subpixelsPerFrame) + return subpixelsPerFrame * (1/256) * 60 +end +``` + +### Frame Independence + +All physics calculations use delta time (`dt`) for frame-rate independent movement: + +```lua +self.groundSpeed = self.groundSpeed - Physics.ACCELERATION * dt +``` + +### Sensor System + +The collision system uses the same sensor layout as the original games: +- Two ground sensors (left and right, 9 pixels apart) +- Two wall sensors (at mid-height) +- Two ceiling sensors + +## References + +- [Sonic Retro Physics Guide](https://info.sonicretro.org/Sonic_Physics_Guide) +- [Sonic 2 Disassembly](https://github.com/sonicretro/s2disasm) + +## License + +This script is provided as part of the WickedEngine samples and is subject to the WickedEngine license. + +## Version History + +- **1.0**: Initial release with Sonic 2 physics implementation diff --git a/Content/scripts/sonic_the_hedgehog/assets/sonic_sprites.png b/Content/scripts/sonic_the_hedgehog/assets/sonic_sprites.png new file mode 100644 index 0000000000..60ce21b1e5 Binary files /dev/null and b/Content/scripts/sonic_the_hedgehog/assets/sonic_sprites.png differ diff --git a/Content/scripts/sonic_the_hedgehog/sonic_the_hedgehog.lua b/Content/scripts/sonic_the_hedgehog/sonic_the_hedgehog.lua new file mode 100644 index 0000000000..127e8596ed --- /dev/null +++ b/Content/scripts/sonic_the_hedgehog/sonic_the_hedgehog.lua @@ -0,0 +1,1273 @@ +-- Sonic The Hedgehog Controller Script for WickedEngine +-- Implements Sonic The Hedgehog 2 physics accurately +-- +-- Physics reference: Sonic Retro Physics Guide +-- All values are converted from the original 256 subpixels per pixel system +-- Original game runs at 60 FPS +-- +-- CONTROLS: +-- Arrow Keys / WASD / Left Analog Stick: Move +-- SHIFT / A Button: Jump +-- DOWN while moving: Roll +-- DOWN + SHIFT while stationary: Spin Dash (charge and release) +-- H: Toggle debug mode +-- R: Reload script +-- ESCAPE: Exit + +-- ============================================================================ +-- SONIC 2 PHYSICS CONSTANTS +-- ============================================================================ +-- Original values are in subpixels (256 per pixel) at 60 FPS +-- We convert to pixels/second for WickedEngine + +local PIXELS_PER_SUBPIXEL = 1 / 256 +local FRAMES_PER_SECOND = 60 + +-- Convert Sonic 2 subpixel/frame values to pixels/second +local function toPixelsPerSecond(subpixelsPerFrame) + return subpixelsPerFrame * PIXELS_PER_SUBPIXEL * FRAMES_PER_SECOND +end + +-- Convert Sonic 2 subpixel/frame^2 values to pixels/second^2 +local function toPixelsPerSecondSq(subpixelsPerFrameSq) + return subpixelsPerFrameSq * PIXELS_PER_SUBPIXEL * FRAMES_PER_SECOND * FRAMES_PER_SECOND +end + +-- Sonic 2 Physics Constants (original subpixel values in comments) +local Physics = { + -- Ground Movement + ACCELERATION = toPixelsPerSecondSq(12), -- 0.046875 (12/256) subpixels/frame^2 + DECELERATION = toPixelsPerSecondSq(128), -- 0.5 (128/256) subpixels/frame^2 + FRICTION = toPixelsPerSecondSq(12), -- 0.046875 (12/256) subpixels/frame^2 + TOP_SPEED = toPixelsPerSecond(1536), -- 6 (1536/256) pixels/frame + + -- Rolling + ROLL_FRICTION = toPixelsPerSecondSq(6), -- 0.0234375 (6/256) subpixels/frame^2 + ROLL_DECELERATION = toPixelsPerSecondSq(32), -- 0.125 (32/256) subpixels/frame^2 + + -- Air Movement + AIR_ACCELERATION = toPixelsPerSecondSq(24), -- 0.09375 (24/256) subpixels/frame^2 + + -- Jumping + JUMP_FORCE = toPixelsPerSecond(1664), -- 6.5 (1664/256) pixels/frame + JUMP_RELEASE_SPEED = toPixelsPerSecond(1024), -- 4 (1024/256) pixels/frame + + -- Gravity + GRAVITY = toPixelsPerSecondSq(56), -- 0.21875 (56/256) subpixels/frame^2 + + -- Slopes + SLOPE_FACTOR = toPixelsPerSecondSq(32), -- 0.125 (32/256) subpixels/frame^2 + SLOPE_FACTOR_ROLLUP = toPixelsPerSecondSq(20), -- 0.078125 (20/256) subpixels/frame^2 + SLOPE_FACTOR_ROLLDOWN = toPixelsPerSecondSq(80), -- 0.3125 (80/256) subpixels/frame^2 + + -- Spin Dash + SPINDASH_BASE = toPixelsPerSecond(2048), -- 8 (2048/256) pixels/frame + SPINDASH_CHARGE = toPixelsPerSecond(512), -- 2 (512/256) pixels/frame per charge + SPINDASH_MAX_CHARGES = 8, + SPINDASH_DECAY = 0.96875, -- Charge decay per frame (floor(charge * 8) / 256) + + -- Speed Caps + SPEED_CAP = toPixelsPerSecond(4096), -- 16 (4096/256) pixels/frame + ROLL_SPEED_THRESHOLD = toPixelsPerSecond(256), -- 1 (256/256) pixel/frame - minimum speed to maintain roll + + -- Animation thresholds + WALK_ANIM_THRESHOLD = toPixelsPerSecond(1024), -- 4 pixels/frame + RUN_ANIM_THRESHOLD = toPixelsPerSecond(1536), -- 6 pixels/frame +} + +-- ============================================================================ +-- INPUT KEY CONSTANTS +-- ============================================================================ + +local Keys = { + A = string.byte('A'), + D = string.byte('D'), + W = string.byte('W'), + S = string.byte('S'), + H = string.byte('H'), + R = string.byte('R'), +} + +-- ============================================================================ +-- LEVEL/DISPLAY CONSTANTS +-- ============================================================================ + +local LevelConfig = { + DEFAULT_GROUND_Y = 400, -- Default ground level for testing + DEFAULT_SCREEN_WIDTH = 800, -- Default screen width + DEFAULT_SCREEN_HEIGHT = 600, -- Default screen height + BOUNDARY_MARGIN = 32, -- Distance from screen edge for boundaries +} + +local DebugConfig = { + TEXT_POS_X = 10, -- Debug text X position + TEXT_POS_Y = 10, -- Debug text Y position + TEXT_SCALE = 0.5, -- Debug text scale + SENSOR_POINT_SIZE = 5, -- Size of center sensor debug point + SENSOR_SIDE_SIZE = 3, -- Size of side sensor debug points +} + +-- ============================================================================ +-- GAME STATE +-- ============================================================================ + +local GameState = { + TITLE = "title", + PLAYING = "playing", + PAUSED = "paused", +} + +-- ============================================================================ +-- SONIC STATES +-- ============================================================================ + +local SonicState = { + IDLE = "idle", + WALKING = "walking", + RUNNING = "running", + JUMPING = "jumping", + ROLLING = "rolling", + SPINDASH = "spindash", + SKIDDING = "skidding", + PUSHING = "pushing", + LOOKING_UP = "looking_up", + CROUCHING = "crouching", + BALANCING = "balancing", + HURT = "hurt", + DYING = "dying", + SPRING = "spring", +} + +-- ============================================================================ +-- SPRITE SHEET CONFIGURATION +-- ============================================================================ +-- Sprite sheet: 10073.png from Sonic 2 (1204x1509 pixels) +-- Each frame is defined as {x, y, width, height} in normalized UV coordinates (0-1) + +local SPRITE_SHEET_WIDTH = 1204 +local SPRITE_SHEET_HEIGHT = 1509 +local SPRITE_SHEET_PATH = "assets/sonic_sprites.png" + +-- Convert pixel coordinates to normalized UV coordinates +local function toUV(x, y, w, h) + return { + x / SPRITE_SHEET_WIDTH, + y / SPRITE_SHEET_HEIGHT, + w / SPRITE_SHEET_WIDTH, + h / SPRITE_SHEET_HEIGHT + } +end + +-- Frame rectangles for each animation (pixel coordinates on sprite sheet) +-- Format: {x, y, width, height} +local SpriteFrames = { + -- Idle/Standing (top left area) + idle = { + {3, 12, 29, 39} -- Standing pose + }, + -- Walking animation (8 frames) + walking = { + {3, 51, 38, 39}, + {42, 51, 29, 40}, + {72, 51, 24, 43}, + {97, 51, 38, 39}, + {136, 51, 29, 40}, + {166, 51, 24, 43}, + {191, 51, 38, 39}, + {230, 51, 29, 40} + }, + -- Running animation (4 frames - blurry legs) + running = { + {3, 94, 32, 37}, + {36, 94, 32, 37}, + {69, 94, 32, 37}, + {102, 94, 32, 37} + }, + -- Rolling/Spin ball (5 frames) + rolling = { + {3, 193, 30, 30}, + {34, 193, 30, 30}, + {65, 193, 30, 30}, + {96, 193, 30, 30}, + {127, 193, 30, 30} + }, + -- Spin dash (same as rolling but charged) + spindash = { + {3, 193, 30, 30}, + {34, 193, 30, 30}, + {65, 193, 30, 30}, + {96, 193, 30, 30}, + {127, 193, 30, 30} + }, + -- Jumping (same as rolling) + jumping = { + {3, 193, 30, 30}, + {34, 193, 30, 30}, + {65, 193, 30, 30}, + {96, 193, 30, 30}, + {127, 193, 30, 30} + }, + -- Skidding/Braking + skidding = { + {3, 131, 35, 39} + }, + -- Pushing animation + pushing = { + {3, 276, 35, 36}, + {39, 276, 38, 36}, + {78, 276, 35, 36}, + {114, 276, 38, 36} + }, + -- Looking up + looking_up = { + {3, 170, 27, 46} + }, + -- Crouching/Ducking + crouching = { + {31, 170, 38, 28} + }, + -- Balancing on edge + balancing = { + {3, 316, 38, 44}, + {42, 316, 38, 46} + }, + -- Hurt/Hit + hurt = { + {3, 364, 43, 34} + }, + -- Dying + dying = { + {3, 364, 43, 34} + }, + -- Spring bounce + spring = { + {3, 12, 29, 39} + } +} + +-- Animation timing configuration +local Animations = { + idle = { speed = 0, loop = false }, + walking = { speed = 0.08, loop = true }, + running = { speed = 0.04, loop = true }, + rolling = { speed = 0.03, loop = true }, + spindash = { speed = 0.02, loop = true }, + jumping = { speed = 0.03, loop = true }, + skidding = { speed = 0, loop = false }, + pushing = { speed = 0.12, loop = true }, + looking_up = { speed = 0, loop = false }, + crouching = { speed = 0, loop = false }, + balancing = { speed = 0.15, loop = true }, + hurt = { speed = 0, loop = false }, + dying = { speed = 0, loop = false }, + spring = { speed = 0, loop = false }, +} + +-- ============================================================================ +-- HELPER FUNCTIONS +-- ============================================================================ + +local function sign(x) + if x > 0 then return 1 + elseif x < 0 then return -1 + else return 0 end +end + +local function clamp(value, min, max) + if value < min then return min end + if value > max then return max end + return value +end + +local function approach(current, target, delta) + if current < target then + return math.min(current + delta, target) + elseif current > target then + return math.max(current - delta, target) + end + return target +end + +-- ============================================================================ +-- SONIC CHARACTER CLASS +-- ============================================================================ + +local function Sonic() + local self = { + -- Position (in pixels, Y increases downward like classic Sonic) + x = 0, + y = 0, + + -- Velocity + xSpeed = 0, -- Horizontal speed + ySpeed = 0, -- Vertical speed + groundSpeed = 0, -- Speed along the ground (considering slopes) + + -- Ground angle (in radians, 0 = flat ground) + groundAngle = 0, + + -- State + state = SonicState.IDLE, + prevState = SonicState.IDLE, + facingRight = true, + isGrounded = true, + isRolling = false, + isJumping = false, + + -- Spin Dash + spindashCharge = 0, + spindashCharges = 0, + + -- Animation + currentAnim = "idle", + animFrame = 1, + animTimer = 0, + + -- Collision + width = 19, -- Hitbox width (push sensors) + height = 39, -- Hitbox height standing + heightRolling = 29, -- Hitbox height rolling + + -- Visual + sprite = nil, + spriteParams = nil, + + -- Input state + inputLeft = false, + inputRight = false, + inputUp = false, + inputDown = false, + inputJump = false, + inputJumpPressed = false, + inputJumpReleased = false, + + -- Control lock (for slope sliding) + controlLockTimer = 0, + + -- Rings and score + rings = 0, + score = 0, + lives = 3, + + -- Invincibility frames + invincibilityTimer = 0, + + -- Debug + debugMode = false, + } + + -- ======================================================================== + -- INITIALIZATION + -- ======================================================================== + + function self:Init(startX, startY) + self.x = startX or 100 + self.y = startY or LevelConfig.DEFAULT_GROUND_Y -- Start at ground level + self.xSpeed = 0 + self.ySpeed = 0 + self.groundSpeed = 0 + self.groundAngle = 0 + self.state = SonicState.IDLE + self.isGrounded = true + self.isRolling = false + self.isJumping = false + self.facingRight = true + + -- Create sprite for visual representation with actual sprite sheet + local spritePath = script_dir() .. SPRITE_SHEET_PATH + self.sprite = Sprite(spritePath) + self.spriteParams = ImageParams() + self.spriteParams.SetSize(Vector(80, 80)) -- Display size (scaled up from ~40px sprites) + self.spriteParams.SetPivot(Vector(0.5, 1.0)) -- Pivot at bottom center for proper grounding + self.spriteParams.SetBlendMode(BLENDMODE_ALPHA) + self.spriteParams.SetQuality(QUALITY_NEAREST) -- Pixel-perfect scaling for retro look + + -- Set initial animation frame + local frame = SpriteFrames.idle[1] + self.spriteParams.EnableDrawRect(Vector( + frame[1] / SPRITE_SHEET_WIDTH, + frame[2] / SPRITE_SHEET_HEIGHT, + (frame[1] + frame[3]) / SPRITE_SHEET_WIDTH, + (frame[2] + frame[4]) / SPRITE_SHEET_HEIGHT + )) + + self.sprite.SetParams(self.spriteParams) + end + + -- ======================================================================== + -- INPUT HANDLING + -- ======================================================================== + + function self:UpdateInput() + local prevJump = self.inputJump + + -- Keyboard input + self.inputLeft = input.Down(KEYBOARD_BUTTON_LEFT) or input.Down(Keys.A) + self.inputRight = input.Down(KEYBOARD_BUTTON_RIGHT) or input.Down(Keys.D) + self.inputUp = input.Down(KEYBOARD_BUTTON_UP) or input.Down(Keys.W) + self.inputDown = input.Down(KEYBOARD_BUTTON_DOWN) or input.Down(Keys.S) + self.inputJump = input.Down(KEYBOARD_BUTTON_LSHIFT) or input.Down(KEYBOARD_BUTTON_RSHIFT) or input.Down(GAMEPAD_BUTTON_2) + + -- Gamepad input (add to keyboard) + local analog = input.GetAnalog(GAMEPAD_ANALOG_THUMBSTICK_L) + if analog.GetX() < -0.3 then self.inputLeft = true end + if analog.GetX() > 0.3 then self.inputRight = true end + if analog.GetY() > 0.3 then self.inputUp = true end + if analog.GetY() < -0.3 then self.inputDown = true end + + -- Detect press and release using input.Press() for more reliable detection + self.inputJumpPressed = input.Press(KEYBOARD_BUTTON_LSHIFT) or input.Press(KEYBOARD_BUTTON_RSHIFT) or input.Press(GAMEPAD_BUTTON_2) + self.inputJumpReleased = not self.inputJump and prevJump + end + + -- ======================================================================== + -- GROUND MOVEMENT (Sonic 2 accurate) + -- ======================================================================== + + function self:GroundMovement(dt) + -- Skip if control is locked (e.g., after sliding off a slope) + if self.controlLockTimer > 0 then + self.controlLockTimer = self.controlLockTimer - dt + -- Still apply friction when locked + self.groundSpeed = approach(self.groundSpeed, 0, Physics.FRICTION * dt) + else + -- Acceleration + if self.inputLeft and not self.inputRight then + if self.groundSpeed > 0 then + -- Skidding (turning around) + self.groundSpeed = self.groundSpeed - Physics.DECELERATION * dt + if self.groundSpeed <= 0 then + self.groundSpeed = -Physics.DECELERATION * dt + end + if not self.isRolling and self.groundSpeed > Physics.TOP_SPEED / 2 then + self.state = SonicState.SKIDDING + end + elseif self.groundSpeed > -Physics.TOP_SPEED then + -- Normal acceleration left + self.groundSpeed = self.groundSpeed - Physics.ACCELERATION * dt + if self.groundSpeed < -Physics.TOP_SPEED then + self.groundSpeed = -Physics.TOP_SPEED + end + end + self.facingRight = false + elseif self.inputRight and not self.inputLeft then + if self.groundSpeed < 0 then + -- Skidding (turning around) + self.groundSpeed = self.groundSpeed + Physics.DECELERATION * dt + if self.groundSpeed >= 0 then + self.groundSpeed = Physics.DECELERATION * dt + end + if not self.isRolling and self.groundSpeed < -Physics.TOP_SPEED / 2 then + self.state = SonicState.SKIDDING + end + elseif self.groundSpeed < Physics.TOP_SPEED then + -- Normal acceleration right + self.groundSpeed = self.groundSpeed + Physics.ACCELERATION * dt + if self.groundSpeed > Physics.TOP_SPEED then + self.groundSpeed = Physics.TOP_SPEED + end + end + self.facingRight = true + else + -- Friction (no input) + self.groundSpeed = approach(self.groundSpeed, 0, Physics.FRICTION * dt) + end + end + + -- Rolling physics (different friction/deceleration) + if self.isRolling then + local friction = Physics.ROLL_FRICTION + if (self.inputLeft and self.groundSpeed > 0) or (self.inputRight and self.groundSpeed < 0) then + -- Actively braking while rolling + friction = Physics.ROLL_DECELERATION + end + self.groundSpeed = approach(self.groundSpeed, 0, friction * dt) + + -- Uncurl if too slow + if math.abs(self.groundSpeed) < Physics.ROLL_SPEED_THRESHOLD then + self.isRolling = false + self.state = SonicState.IDLE + end + end + + -- Slope factor (gravity effect on slopes) + if self.groundAngle ~= 0 then + local slopeFactor = Physics.SLOPE_FACTOR + if self.isRolling then + if sign(self.groundSpeed) == sign(math.sin(self.groundAngle)) then + slopeFactor = Physics.SLOPE_FACTOR_ROLLUP -- Going uphill + else + slopeFactor = Physics.SLOPE_FACTOR_ROLLDOWN -- Going downhill + end + end + self.groundSpeed = self.groundSpeed - slopeFactor * math.sin(self.groundAngle) * dt + end + + -- Convert ground speed to X/Y velocity + self.xSpeed = self.groundSpeed * math.cos(self.groundAngle) + self.ySpeed = self.groundSpeed * -math.sin(self.groundAngle) + end + + -- ======================================================================== + -- AIR MOVEMENT (Sonic 2 accurate) + -- ======================================================================== + + function self:AirMovement(dt) + -- Horizontal air control + if self.inputLeft and not self.inputRight then + if self.xSpeed > -Physics.TOP_SPEED then + self.xSpeed = self.xSpeed - Physics.AIR_ACCELERATION * dt + if self.xSpeed < -Physics.TOP_SPEED then + self.xSpeed = -Physics.TOP_SPEED + end + end + elseif self.inputRight and not self.inputLeft then + if self.xSpeed < Physics.TOP_SPEED then + self.xSpeed = self.xSpeed + Physics.AIR_ACCELERATION * dt + if self.xSpeed > Physics.TOP_SPEED then + self.xSpeed = Physics.TOP_SPEED + end + end + end + + -- Air drag (when moving upward and fast) + if self.ySpeed < 0 and self.ySpeed > -Physics.JUMP_RELEASE_SPEED then + if math.abs(self.xSpeed) >= Physics.ACCELERATION * 60 / 32 then + -- Original formula: xSpeed -= xSpeed / 32 per frame at 60 FPS + self.xSpeed = self.xSpeed - (self.xSpeed / 32) * (dt * 60) + end + end + + -- Apply gravity + self.ySpeed = self.ySpeed + Physics.GRAVITY * dt + + -- Speed cap + if self.ySpeed > Physics.SPEED_CAP then + self.ySpeed = Physics.SPEED_CAP + end + + -- Variable jump height (release jump button early) + if self.isJumping and self.inputJumpReleased and self.ySpeed < -Physics.JUMP_RELEASE_SPEED then + self.ySpeed = -Physics.JUMP_RELEASE_SPEED + end + end + + -- ======================================================================== + -- JUMPING + -- ======================================================================== + + function self:TryJump() + if self.isGrounded and self.inputJumpPressed then + -- Calculate jump velocity considering ground angle + self.xSpeed = self.xSpeed - Physics.JUMP_FORCE * math.sin(self.groundAngle) + self.ySpeed = -Physics.JUMP_FORCE * math.cos(self.groundAngle) + + self.isGrounded = false + self.isJumping = true + self.state = SonicState.JUMPING + + -- If was rolling, maintain roll in air + if self.isRolling then + self.state = SonicState.JUMPING + end + end + end + + -- ======================================================================== + -- ROLLING + -- ======================================================================== + + function self:TryRoll() + if self.isGrounded and not self.isRolling then + if self.inputDown and math.abs(self.groundSpeed) >= Physics.ROLL_SPEED_THRESHOLD then + self.isRolling = true + self.state = SonicState.ROLLING + end + end + end + + -- ======================================================================== + -- SPIN DASH (Sonic 2 feature) + -- ======================================================================== + + function self:UpdateSpindash(dt) + if self.state == SonicState.SPINDASH then + -- Charge decay + self.spindashCharge = self.spindashCharge * math.pow(Physics.SPINDASH_DECAY, dt * 60) + + -- Add charge on button press + if self.inputJumpPressed then + self.spindashCharges = math.min(self.spindashCharges + 1, Physics.SPINDASH_MAX_CHARGES) + self.spindashCharge = self.spindashCharge + Physics.SPINDASH_CHARGE + end + + -- Release spin dash + if not self.inputDown then + local launchSpeed = Physics.SPINDASH_BASE + self.spindashCharge + self.groundSpeed = self.facingRight and launchSpeed or -launchSpeed + self.isRolling = true + self.state = SonicState.ROLLING + self.spindashCharge = 0 + self.spindashCharges = 0 + end + elseif self.isGrounded and self.inputDown and math.abs(self.groundSpeed) < 0.1 then + -- Initiate spin dash + if self.inputJumpPressed then + self.state = SonicState.SPINDASH + self.spindashCharge = 0 + self.spindashCharges = 0 + end + end + end + + -- ======================================================================== + -- LOOKING UP / CROUCHING + -- ======================================================================== + + function self:UpdateLookUpCrouch() + if self.isGrounded and math.abs(self.groundSpeed) < 0.1 and not self.isRolling then + if self.inputUp and not self.inputDown then + self.state = SonicState.LOOKING_UP + elseif self.inputDown and not self.inputUp then + if self.state ~= SonicState.SPINDASH then + self.state = SonicState.CROUCHING + end + elseif self.state == SonicState.LOOKING_UP or self.state == SonicState.CROUCHING then + self.state = SonicState.IDLE + end + end + end + + -- ======================================================================== + -- STATE MANAGEMENT + -- ======================================================================== + + function self:UpdateState() + self.prevState = self.state + + if not self.isGrounded then + if self.isJumping or self.isRolling then + self.state = SonicState.JUMPING + else + -- Fell off a ledge + self.state = SonicState.WALKING + end + elseif self.state == SonicState.SPINDASH then + -- Handled in UpdateSpindash + elseif self.isRolling then + self.state = SonicState.ROLLING + elseif math.abs(self.groundSpeed) < 0.1 then + if self.state ~= SonicState.LOOKING_UP and self.state ~= SonicState.CROUCHING then + self.state = SonicState.IDLE + end + elseif self.state ~= SonicState.SKIDDING then + if math.abs(self.groundSpeed) >= Physics.RUN_ANIM_THRESHOLD then + self.state = SonicState.RUNNING + else + self.state = SonicState.WALKING + end + else + -- Check if skidding is done + if sign(self.groundSpeed) == (self.facingRight and 1 or -1) or self.groundSpeed == 0 then + self.state = SonicState.WALKING + end + end + end + + -- ======================================================================== + -- ANIMATION + -- ======================================================================== + + function self:UpdateAnimation(dt) + local animName = "idle" + + -- Map state to animation + if self.state == SonicState.IDLE then animName = "idle" + elseif self.state == SonicState.WALKING then animName = "walking" + elseif self.state == SonicState.RUNNING then animName = "running" + elseif self.state == SonicState.JUMPING then animName = "jumping" + elseif self.state == SonicState.ROLLING then animName = "rolling" + elseif self.state == SonicState.SPINDASH then animName = "spindash" + elseif self.state == SonicState.SKIDDING then animName = "skidding" + elseif self.state == SonicState.PUSHING then animName = "pushing" + elseif self.state == SonicState.LOOKING_UP then animName = "looking_up" + elseif self.state == SonicState.CROUCHING then animName = "crouching" + elseif self.state == SonicState.BALANCING then animName = "balancing" + elseif self.state == SonicState.HURT then animName = "hurt" + elseif self.state == SonicState.DYING then animName = "dying" + end + + -- Handle animation change + if animName ~= self.currentAnim then + self.currentAnim = animName + self.animFrame = 1 + self.animTimer = 0 + end + + -- Get animation config and frames + local anim = Animations[animName] + local frames = SpriteFrames[animName] + + if anim and frames and #frames > 0 then + -- Animate if looping and has speed + if anim.loop and anim.speed > 0 then + -- Adjust animation speed based on ground speed for walking/running + local animSpeed = anim.speed + if animName == "walking" or animName == "running" then + local speedFactor = math.abs(self.groundSpeed) / Physics.TOP_SPEED + animSpeed = anim.speed / (0.5 + speedFactor) + end + + self.animTimer = self.animTimer + dt + if self.animTimer >= animSpeed then + self.animTimer = self.animTimer - animSpeed + self.animFrame = self.animFrame + 1 + if self.animFrame > #frames then + self.animFrame = 1 + end + end + end + + -- Update sprite draw rect to show current frame + local frame = frames[self.animFrame] or frames[1] + if frame then + self.spriteParams.EnableDrawRect(Vector( + frame[1] / SPRITE_SHEET_WIDTH, + frame[2] / SPRITE_SHEET_HEIGHT, + (frame[1] + frame[3]) / SPRITE_SHEET_WIDTH, + (frame[2] + frame[4]) / SPRITE_SHEET_HEIGHT + )) + end + end + end + + -- ======================================================================== + -- COLLISION DETECTION + -- ======================================================================== + + function self:CheckCollision(dt) + -- Basic ground collision (simplified - a full implementation would use sensors) + -- In a real implementation, you'd use the scene's Intersects function with rays + + local groundY = LevelConfig.DEFAULT_GROUND_Y + + if not self.isGrounded then + -- Check if landing + if self.ySpeed > 0 and self.y + self.ySpeed * dt >= groundY then + self.y = groundY + self.ySpeed = 0 + self.isGrounded = true + self.isJumping = false + self.groundSpeed = self.xSpeed -- Transfer horizontal speed to ground speed + self.groundAngle = 0 + + if self.isRolling then + self.state = SonicState.ROLLING + else + self.isRolling = false + end + end + else + -- Stay on ground (simplified) + self.y = groundY + end + + -- Left/Right boundaries (for testing) + local minX = LevelConfig.BOUNDARY_MARGIN + local maxX = LevelConfig.DEFAULT_SCREEN_WIDTH - LevelConfig.BOUNDARY_MARGIN + + if self.x < minX then + self.x = minX + self.groundSpeed = 0 + self.xSpeed = 0 + end + if self.x > maxX then + self.x = maxX + self.groundSpeed = 0 + self.xSpeed = 0 + end + end + + -- ======================================================================== + -- SCENE COLLISION (Using WickedEngine's scene intersection) + -- ======================================================================== + + function self:CheckSceneCollision(scene, dt) + -- Ground detection using multiple downward rays (sensor system) + local sensorSpacing = 9 -- Distance between ground sensors + local floorCheckDistance = 16 -- How far to look for ground + + local leftSensorX = self.x - sensorSpacing + local rightSensorX = self.x + sensorSpacing + local sensorY = self.y + + local foundGround = false + local closestGroundY = self.y + floorCheckDistance + local closestAngle = 0 + + -- Cast rays downward from both sensors + for i, sensorX in ipairs({leftSensorX, self.x, rightSensorX}) do + local ray = Ray( + Vector(sensorX, sensorY - 10, 0), -- Start slightly above current position + Vector(0, 1, 0), -- Cast downward + 0, + floorCheckDistance + 10 + ) + + local entity, position, normal = scene.Intersects(ray, FILTER_NAVIGATION_MESH) + if entity ~= INVALID_ENTITY then + local groundY = position.GetY() + if groundY < closestGroundY then + closestGroundY = groundY + closestAngle = math.atan2(normal.GetX(), normal.GetY()) + foundGround = true + end + end + end + + if foundGround and self.ySpeed >= 0 then + local groundDist = closestGroundY - self.y + if groundDist <= floorCheckDistance and groundDist >= -8 then + self.y = closestGroundY + self.ySpeed = 0 + self.isGrounded = true + self.isJumping = false + self.groundSpeed = self.xSpeed * math.cos(closestAngle) + self.ySpeed * math.sin(closestAngle) + self.groundAngle = closestAngle + end + end + + -- Wall detection (left and right) + local wallCheckDistance = self.width / 2 + 1 + local heightCheck = self.isRolling and self.heightRolling or self.height + local wallCheckY = self.y - heightCheck / 2 + + -- Left wall + local leftRay = Ray( + Vector(self.x, wallCheckY, 0), + Vector(-1, 0, 0), + 0, + wallCheckDistance + ) + local leftEntity, leftPos = scene.Intersects(leftRay, FILTER_NAVIGATION_MESH) + if leftEntity ~= INVALID_ENTITY then + if self.xSpeed < 0 then + self.x = leftPos.GetX() + self.width / 2 + self.xSpeed = 0 + if self.isGrounded then self.groundSpeed = 0 end + end + end + + -- Right wall + local rightRay = Ray( + Vector(self.x, wallCheckY, 0), + Vector(1, 0, 0), + 0, + wallCheckDistance + ) + local rightEntity, rightPos = scene.Intersects(rightRay, FILTER_NAVIGATION_MESH) + if rightEntity ~= INVALID_ENTITY then + if self.xSpeed > 0 then + self.x = rightPos.GetX() - self.width / 2 + self.xSpeed = 0 + if self.isGrounded then self.groundSpeed = 0 end + end + end + + -- Ceiling detection + local ceilingRay = Ray( + Vector(self.x, self.y - heightCheck, 0), + Vector(0, -1, 0), + 0, + math.abs(self.ySpeed * dt) + 1 + ) + local ceilEntity, ceilPos = scene.Intersects(ceilingRay, FILTER_NAVIGATION_MESH) + if ceilEntity ~= INVALID_ENTITY and self.ySpeed < 0 then + self.y = ceilPos.GetY() + heightCheck + self.ySpeed = 0 + end + end + + -- ======================================================================== + -- MAIN UPDATE + -- ======================================================================== + + function self:Update(dt) + self:UpdateInput() + + -- Physics update + if self.isGrounded then + self:TryJump() + self:UpdateSpindash(dt) + self:TryRoll() + self:UpdateLookUpCrouch() + + if self.state ~= SonicState.SPINDASH then + self:GroundMovement(dt) + end + else + self:AirMovement(dt) + end + + -- Apply velocity to position + self.x = self.x + self.xSpeed * dt + self.y = self.y + self.ySpeed * dt + + -- Collision detection + self:CheckCollision(dt) + + -- Update state based on current conditions + self:UpdateState() + + -- Animation + self:UpdateAnimation(dt) + + -- Update invincibility timer + if self.invincibilityTimer > 0 then + self.invincibilityTimer = self.invincibilityTimer - dt + end + end + + -- ======================================================================== + -- RENDERING + -- ======================================================================== + + function self:Render(path) + -- Update sprite position and orientation + local canvas = application.GetCanvas() + local screenX = self.x + local screenY = canvas.GetLogicalHeight() - self.y -- Convert to screen coordinates (Y up) + + self.spriteParams.SetPos(Vector(screenX, screenY)) + + -- Flip sprite based on facing direction + if self.facingRight then + self.spriteParams.DisableMirror() + else + self.spriteParams.EnableMirror() + end + + -- Rotation for slopes (when grounded) + if self.isGrounded then + self.spriteParams.SetRotation(-self.groundAngle) + else + self.spriteParams.SetRotation(0) + end + + -- Blinking when invincible + if self.invincibilityTimer > 0 then + local blink = math.floor(self.invincibilityTimer * 10) % 2 + self.spriteParams.SetOpacity(blink == 0 and 1 or 0.3) + else + self.spriteParams.SetOpacity(1) + end + + self.sprite.SetParams(self.spriteParams) + end + + -- ======================================================================== + -- DEBUG RENDERING + -- ======================================================================== + + function self:RenderDebug() + local canvas = application.GetCanvas() + local debugY = 10 + local lineHeight = 15 + + local function debugLine(text) + local font = SpriteFont(text) + font.SetPos(Vector(10, debugY)) + font.SetSize(12) + font.SetColor(Vector(1, 1, 0, 1)) + debugY = debugY + lineHeight + return font + end + + local debugInfo = string.format( + "Sonic Debug Info:\n" .. + "Position: (%.1f, %.1f)\n" .. + "Ground Speed: %.2f px/s\n" .. + "X Speed: %.2f px/s\n" .. + "Y Speed: %.2f px/s\n" .. + "Ground Angle: %.2f deg\n" .. + "State: %s\n" .. + "Grounded: %s\n" .. + "Rolling: %s\n" .. + "Facing: %s\n" .. + "Rings: %d\n", + self.x, self.y, + self.groundSpeed, + self.xSpeed, + self.ySpeed, + math.deg(self.groundAngle), + self.state, + tostring(self.isGrounded), + tostring(self.isRolling), + self.facingRight and "Right" or "Left", + self.rings + ) + + DrawDebugText(debugInfo, Vector(DebugConfig.TEXT_POS_X, DebugConfig.TEXT_POS_Y, 0), Vector(1, 1, 0, 1), DebugConfig.TEXT_SCALE) + + -- Draw collision sensors + local sensorY = canvas.GetLogicalHeight() - self.y + DrawPoint(Vector(self.x, sensorY, 0), DebugConfig.SENSOR_POINT_SIZE, Vector(0, 1, 0, 1)) + DrawPoint(Vector(self.x - 9, sensorY, 0), DebugConfig.SENSOR_SIDE_SIZE, Vector(0, 0, 1, 1)) + DrawPoint(Vector(self.x + 9, sensorY, 0), DebugConfig.SENSOR_SIDE_SIZE, Vector(1, 0, 0, 1)) + end + + return self +end + +-- ============================================================================ +-- HUD +-- ============================================================================ + +local function HUD() + local self = { + scoreFont = nil, + timeFont = nil, + ringsFont = nil, + livesFont = nil, + time = 0, + } + + function self:Init() + self.scoreFont = SpriteFont("SCORE: 0") + self.scoreFont.SetSize(20) + self.scoreFont.SetColor(Vector(1, 1, 0, 1)) + + self.timeFont = SpriteFont("TIME: 0:00") + self.timeFont.SetSize(20) + self.timeFont.SetColor(Vector(1, 1, 1, 1)) + + self.ringsFont = SpriteFont("RINGS: 0") + self.ringsFont.SetSize(20) + self.ringsFont.SetColor(Vector(1, 1, 0, 1)) + + self.livesFont = SpriteFont("LIVES: 3") + self.livesFont.SetSize(20) + self.livesFont.SetColor(Vector(1, 1, 1, 1)) + end + + function self:Update(dt, sonic) + self.time = self.time + dt + + local minutes = math.floor(self.time / 60) + local seconds = math.floor(self.time % 60) + + self.scoreFont.SetText(string.format("SCORE: %d", sonic.score)) + self.timeFont.SetText(string.format("TIME: %d:%02d", minutes, seconds)) + self.ringsFont.SetText(string.format("RINGS: %d", sonic.rings)) + self.livesFont.SetText(string.format("LIVES: %d", sonic.lives)) + + self.scoreFont.SetPos(Vector(16, 16)) + self.timeFont.SetPos(Vector(16, 40)) + self.ringsFont.SetPos(Vector(16, 64)) + self.livesFont.SetPos(Vector(16, 88)) + end + + function self:AddToPath(path) + path.AddFont(self.scoreFont) + path.AddFont(self.timeFont) + path.AddFont(self.ringsFont) + path.AddFont(self.livesFont) + end + + return self +end + +-- ============================================================================ +-- EXPORT FOR MODULE USE +-- ============================================================================ +-- This allows the script to be imported into any scene + +local SonicModule = { + -- Export the Sonic character class + Sonic = Sonic, + + -- Export the HUD class + HUD = HUD, + + -- Export physics constants for customization + Physics = Physics, + + -- Export state constants + SonicState = SonicState, + + -- Export level config + LevelConfig = LevelConfig, + + -- Export sprite frames for customization + SpriteFrames = SpriteFrames, + + -- Export animations for customization + Animations = Animations, + + -- Helper to create a complete Sonic setup for any render path + CreateSonicForPath = function(path, startX, startY, includeHUD) + local sonic = Sonic() + sonic:Init(startX or 100, startY or LevelConfig.DEFAULT_GROUND_Y) + path.AddSprite(sonic.sprite) + + local hud = nil + if includeHUD ~= false then -- default true + hud = HUD() + hud:Init() + hud:AddToPath(path) + end + + return sonic, hud + end, + + -- Helper to run Sonic update/render in an existing game loop + UpdateSonic = function(sonic, dt) + sonic:Update(dt) + end, + + RenderSonic = function(sonic, path) + sonic:Render(path) + end, + + -- Set custom ground level + SetGroundLevel = function(y) + LevelConfig.DEFAULT_GROUND_Y = y + end, + + -- Set custom screen bounds + SetScreenBounds = function(width, height, margin) + LevelConfig.DEFAULT_SCREEN_WIDTH = width or 800 + LevelConfig.DEFAULT_SCREEN_HEIGHT = height or 600 + LevelConfig.BOUNDARY_MARGIN = margin or 32 + end +} + +-- Make module available globally for import +_G.SonicModule = SonicModule + +-- ============================================================================ +-- STANDALONE DEMO (runs when script is executed directly) +-- ============================================================================ + +runProcess(function() + -- Store previous path to restore on exit + local prevPath = application.GetActivePath() + + -- Create render path (2D for classic Sonic style) + local path = RenderPath2D() + + -- Apply render path + application.SetActivePath(path, 0.5) + + -- Wait for transition + while not application.IsFaded() do + update() + end + waitSeconds(0.5) + + -- Initialize game objects + local sonic = Sonic() + sonic:Init(100, LevelConfig.DEFAULT_GROUND_Y) -- Start at ground level + + local hud = HUD() + hud:Init() + hud:AddToPath(path) + + -- Add Sonic sprite to render path + path.AddSprite(sonic.sprite) + + -- Create simple ground visual (temporary - for testing) + local groundSprite = Sprite() + local groundParams = ImageParams() + groundParams.SetColor(Vector(0.2, 0.6, 0.2, 1)) + groundParams.SetSize(Vector(800, 100)) + groundParams.SetPos(Vector(0, 400)) + groundSprite.SetParams(groundParams) + path.AddSprite(groundSprite) + + -- Help text + local helpText = [[ +Sonic The Hedgehog - WickedEngine Demo +======================================== +Controls: + Arrow Keys / WASD: Move + SHIFT: Jump + DOWN while moving: Roll + DOWN + SHIFT (stationary): Spin Dash + H: Toggle debug mode + R: Reload script + ESCAPE: Exit + +Physics: Sonic The Hedgehog 2 accurate +]] + + local helpFont = SpriteFont(helpText) + helpFont.SetSize(14) + helpFont.SetColor(Vector(1, 1, 1, 0.8)) + helpFont.SetPos(Vector(10, 450)) + path.AddFont(helpFont) + + -- Title + local titleFont = SpriteFont("SONIC THE HEDGEHOG") + titleFont.SetSize(32) + titleFont.SetColor(Vector(0, 0.5, 1, 1)) + titleFont.SetPos(Vector(200, 20)) + path.AddFont(titleFont) + + -- Debug mode flag + local debugMode = false + + backlog_post("Sonic The Hedgehog script loaded!") + backlog_post("Press H to toggle debug mode, R to reload, ESC to exit") + + -- Main game loop + while true do + local dt = getDeltaTime() + + -- Update game + sonic:Update(dt) + hud:Update(dt, sonic) + + -- Render + sonic:Render(path) + + -- Debug rendering + if debugMode then + sonic:RenderDebug() + end + + update() + + -- Input handling for debug and exit + if not backlog_isactive() then + -- Toggle debug mode + if input.Press(Keys.H) then + debugMode = not debugMode + sonic.debugMode = debugMode + end + + -- Reload script + if input.Press(Keys.R) then + backlog_post("Reloading Sonic script...") + application.SetActivePath(prevPath, 0.5) + while not application.IsFaded() do + update() + end + killProcesses() + dofile(script_file()) + return + end + + -- Exit + if input.Press(KEYBOARD_BUTTON_ESCAPE) then + backlog_post("Exiting Sonic script...") + application.SetActivePath(prevPath, 0.5) + killProcesses() + return + end + end + end +end) + +-- Return the module for use with dofile/require +return SonicModule