- Preview
- Motivation
- Challenges
- Tech stack
- Feature
- Timeline
- Video
- Repository Link
- Memoir
Climbing, Mathematics, Physics - I gathered elements I love to conceptualize this idea.
Rather than just knowing how to use a specific library, I wanted to show the process of logically developing ideas based on math and physics formulas that everyone has learned. I wanted to challenge the unfamiliar problem of expressing human joint movements and implementing a physics engine.
I thought it would be nice to have a service where you can practice climbing movements even if you don't go to a climbing gym.
So I planned this game not just for fun, but to have something to learn from.
- Developing the habit of thinking about lower body movements
- When actually raising the center of gravity, it's more efficient in terms of stamina to first raise the feet and raise the center of gravity with lower body strength, not just pulling up with the arms.
- Practicing route finding
- Route finding: Judging which moves and sequence to grab holds by looking at the holds.
Each body part was created as a new Graphics()
object.
// src/utils/player.js
export const body = new Graphics();
const leftUpperArm = new Graphics();
const leftForeArm = new Graphics();
const leftHand = new Graphics();
const rightUpperArm = new Graphics();
const rightForeArm = new Graphics();
const rightHand = new Graphics();
const leftThigh = new Graphics();
const leftCalf = new Graphics();
const leftFoot = new Graphics();
const rightThigh = new Graphics();
const rightCalf = new Graphics();
const rightFoot = new Graphics();
* Upper Arm: Refers to the part of the arm from the shoulder to the elbow. * Forearm: Refers to the part of the arm from the elbow to the wrist.
The forearms and upper arms of the arms (legs) are represented as Line
objects that can be drawn with a start point and x,y changes (upperArmDxy
).
// src/utils/drawLimb.js
const upperArmDxy = {
dx: limbLength * getCos(upperArmAngle) * flagX,
dy: -limbLength * getSin(upperArmAngle) * flagY,
};
upperArm.position.set(shoulder.x + flagX * flagY, shoulder.y);
upperArm
.lineStyle(limbWidth + 3, COLOR.SKIN)
.lineTo(upperArmDxy.dx, upperArmDxy.dy);
drawLimb.js
full code
The coordinates of the hand and shoulder are always known. Therefore, in the above figure, theta1
and theta2
can be calculated.
// src/utils/moveJoint.js
const handToShoulder = getDistance(shoulder, cursorInContainer);
const h = Math.sqrt(limbLength ** 2 - (handToShoulder / 2) ** 2) || 0; // Height of the isosceles triangle HES
const theta1 = getAngleDegrees(handToShoulder / 2, h);
const theta2 = getAngleDegrees(
flagX * (shoulder.x - cursorInContainer.x),
shoulder.y - cursorInContainer.y
);
The lengths of the forearm and upper arm are set to be equal,
and by calculating the angle (theta1
- theta2
) that the upper arm makes with the ground, the elbow (elbow
) coordinates can be calculated.
const elbow = {
x: shoulder.x - flagX - limbLength * getCos(theta1 - theta2) * flagX,
y: shoulder.y + limbLength * getSin(theta1 - theta2),
};
Based on the elbow (elbow
) coordinates, the upper arm (upperArm
) and forearm (foreArm
) are drawn.
const upperArmDxy = {
x: elbow.x - shoulder.x,
y: elbow.y - shoulder.y,
};
upperArm
.lineStyle(limbWidth + 3, COLOR.SKIN)
.lineTo(upperArmDxy.x, upperArmDxy.y);
foreArm.position.set(elbow.x, elbow.y);
foreArm
.lineStyle(limbWidth, COLOR.SKIN)
.lineTo(hand.x - elbow.x, hand.y - elbow.y);
🔽 The arms bend naturally according to the hand’s position
Climbing movements are the result of complex movements of several body parts.
(ex. Reaching out to grab a distant object by dragging the hand, moving the torso in the direction of the hand while moving other joints)
Initially, I raised the center of gravity by extending the hands upwards, raising the torso, and extending the hands.
However, this movement was unnatural and uncomfortable from the user’s perspective.
Therefore, when extending one hand, I implemented the movement through the following steps.
-
First, execute the function
moveJoint()
that moves the arm joints.
In this process, if the distance from the hand to the shoulder exceeds the arm length,theta2
is returned, -
Then execute the function
moveBodyTo()
that moves the torso.
const theta2 = moveJoint(
...leftArmList,
...armSize,
cursorInContainer,
1,
1,
handRadius
);
if (!theta2) return;
return moveBodyTo({
x: cursorInContainer.x + armLength * 2 * getCos(theta2) + BODY.WIDTH / 2,
y: cursorInContainer.y + armLength * 2 * getSin(theta2) + BODY.HEIGHT / 2,
});
moveBodyTo()
changes the position of the torso and within the function executesmoveJointBody()
to move other limbs naturally according to the torso’s position.
Motivation for implementing without external libraries
I thought I could implement it directly without a 3rd party library because as long as I can represent the body’s parts’ accelerated circular motion under gravity acceleration, it would suffice.
- Create a gravity function that acts on all objects. ❌
- Advantages
- Can be used universally for objects other than body parts.
- Easy to maintain.
- Problems
- Advantages
- Create a human body gravity function in the form of a
Pixi.JS
-based plugin. ✅- Gravity always acts, but assuming that in certain situations, gravity acts more strongly than the sum of other forces, causing body parts to undergo downward accelerated circular motion or the player to move downward, I structured the logic accordingly.
The arm (leg) undergoes accelerated circular motion in the direction of gravity with the shoulder (hip) as the rotation axis.
-
How to know if a hand has fallen off a hold?
Afterpointerdown
event is triggered on each hold, if a pointerup event occurs on thecanvas
, execute the gravity function.
⇒ Since the hands and feet are always on holds, holds cannot detectpointerdown
events.- Detect based on hold positions.
When apointerup
event occurs on the hands/feet, if the hand/foot is not within the defined hold coordinates, execute the gravity function. (Arms/legs undergo accelerated circular motion with the shoulder/hip as the rotation axis.)- At this time, it is considered that HP (health points) are consumed significantly, so HP decreases rapidly.
-
How to represent accelerated circular motion?
- Execute the
gravityRotate()
function to increase the rotation angular velocity (angleVelocity
) of the upper arm and forearm at a constant acceleration. - Since gravity always acts downward, until the angle between the line perpendicular to the ground and the arm is achieved,
increase the upper arm’s angle (
upperArm.angle
) and forearm’s angle (foreArm.angle
).
// src/utils/gravityRotate.js const handToShoulder = getDistance(shoulder, hand); const h = Math.sqrt(limbLength ** 2 - (handToShoulder / 2) ** 2) || 0; const theta1 = getAngleDegrees(handToShoulder / 2, h); const upperArmOriginalAngle = getAngleDegrees( foreArm.y - shoulder.y, foreArm.x - shoulder.x ); const rotatingDirection = upperArmOriginalAngle / Math.abs(upperArmOriginalAngle); let angleVelocity = 0; function rotateArm() { angleVelocity += 0.5; const isUpperArmRotating = Math.abs(upperArm.angle) < Math.abs(upperArmOriginalAngle); const foreArmRotatingGoal = Math.abs(upperArmOriginalAngle) + theta1 * 2 * rotatingDirection * flagX; const isForeArmRotating = Math.abs(foreArm.angle) < foreArmRotatingGoal; if (isUpperArmRotating) { upperArm.angle += angleVelocity * 0.2 * rotatingDirection; const newAngle = upperArmOriginalAngle - upperArm.angle; foreArm.x = shoulder.x + limbLength * getSin(newAngle); foreArm.y = shoulder.y + limbLength * getCos(newAngle); } if (isForeArmRotating) { foreArm.angle += angleVelocity * 0.2 * rotatingDirection; } const newAngle = foreArmRotatingGoal - Math.abs(foreArm.angle); hand.x = foreArm.x + limbLength * getSin(newAngle) * rotatingDirection; hand.y = foreArm.y + limbLength * getCos(newAngle); const isRotationFinished = !isUpperArmRotating && !isForeArmRotating; if (isRotationFinished) { return drawLimb( ... ); // After rotation ends, draw new limbs with angles reset to 0. } requestAnimationFrame(rotateArm); } rotateArm();
- Execute the
The whole body undergoes accelerated downward motion.
-
Save the number of hands/feet within the hold coordinate range in a variable.
- Each is initially 1, and every time
onDragEnd()
is executed, check whether the hands/feet are in hold positions and update the variable accordingly.
- Each is initially 1, and every time
-
When executing the
onDragStart()
function, check how many hands there are, and if it’s 0, executefallDown()
function. -
Executing
fallDown()
will cause the player to move downward with the descent speed (descentVelocity
) increasing at a constant acceleration until theplayerContainer
touches the ground.function fallDown(displayText) { let descentVelocity = 0; function descend() { descentVelocity += 0.4; playerContainer.y += descentVelocity * 0.3; const isPlayerAboveGround = playerContainer.y < containerPosition.y - leftShoulder.y + (initialContainerHeight - playerContainer.height); if (!isPlayerAboveGround) { gameStatus.fail = true; holdContainer.addChild(getResultText(displayText)); return; } requestAnimationFrame(descend); } descend(); }
The center of gravity is pulled down by gravity until one arm is extended.
- When the drag ends and
onDragEnd()
function is executed, executecheckGravityCenter()
function to check if the center of gravity’s x-coordinate is between both feet. - If the center of gravity’s x-coordinate is not between both feet, execute
descendByGravity()
function to move the torso downward.
function checkGravityCenter() {
const gravityCenterX = body.x + BODY.WIDTH / 2;
attachedStatus.isStable =
leftFoot.x < gravityCenterX && gravityCenterX < rightFoot.x;
if (!attachedStatus.isStable) {
descendByGravity();
}
function descendByGravity() { ... }
}
- At this time, it is considered that the player’s HP is consumed significantly, so HP decreases rapidly.
* hand
: Refers to hand/foot.
- Register the
pointermove
event on thehand
object to execute theonDragging()
function when dragging the hand with the cursor. - In the
onDragging()
function, execute themoveJoint()
function, and in themoveJoint()
function, update thehand
‘s x,y coordinates to the cursor’s x,y coordinates to move thehand
.
- The hand movement speed couldn’t keep up with the mouse drag speed, causing intermittent actions during drag and degrading the user experience.
- When the cursor movement speed is fast, the
hand
‘s x,y coordinates were not updated in real-time to follow the cursor, resulting in frequent instances where the cursor’s position exceeds thehand
’s position.
-
Register the
addEventListener("pointermove", onDragging)
event not on thehand
object but on the background wall represented bydocument.querySelector(".wall")
.const wall = document.querySelector(".wall"); wall.addEventListener("pointermove", onDragging);
-
Even if the cursor position exceeds the
hand
’s coordinate range (i.e., the distance from the shoulder to the cursor becomes greater than the arm length (limbLength * 2
)), update thehand
’s coordinates to ensure that theshoulder→hand vector
is in the same direction as theshoulder→cursor vector
but with a fixed length equal to the arm length.// src/utils/moveJoint.js const cursorToShoulder = getDistance(shoulder, cursorInContainer); ... if (cursorToShoulder > limbLength * 2) { hand.x = shoulder.x - limbLength * 2 * getCos(theta2) * flagX; hand.y = shoulder.y - limbLength * 2 * getSin(theta2); } else { hand.x = cursorInContainer.x; hand.y = cursorInContainer.y; }
- Even if the user moves the cursor quickly so that the cursor position slightly exceeds the hand, the hand moves towards the cursor.
- Even if the hand is dragged beyond the arm length, the hand remains attached to the arm but moves in the direction of the cursor.
-
After one arm/leg is extended (by dragging or dropping due to gravity making the arm/leg fall downward), when dragging the other hand/foot, the previously extended limb did not bend.
-
Bending the extended limb by directly dragging the extended hand/foot was possible, but it was inconvenient from the user’s perspective.
- When one arm/leg is extended, dragging the other hand/foot did not execute the torso movement function.
- To prevent limbs from detaching from the body, the torso movement function was designed to not work if one limb is extended.
-
Create a function
rearrangeBody()
that rearranges body parts. -
Call this function rearrangeBody() either when a
pointerup
event occurs or after a limb is extended straight down by gravity. -
In
rearrangeBody()
, assign the direction in which the hands/feet are positioned based on the shoulder/hip to aflag
variable, and executemoveBodyTo()
to rearrange the body parts.function rearrangeBody(part) { if (!attachedStatus.leftHand && dragTarget !== leftHand) { leftHand.position.set(leftShoulder.x, leftShoulder.y + armLength * 2 - 2); } else if (!attachedStatus.rightHand && dragTarget !== rightHand) { rightHand.position.set( rightShoulder.x, rightShoulder.y + armLength * 2 - 2 ); } else if (!attachedStatus.leftFoot && dragTarget !== leftFoot) { leftFoot.position.set(leftCoxa.x, leftCoxa.y + legLength * 2 - 2); } else if (!attachedStatus.rightFoot && dragTarget !== rightFoot) { rightFoot.position.set(rightCoxa.x, rightCoxa.y + legLength * 2 - 2); } if (!part) return; const flag = { x: null, y: null }; flag.x = part.hand.x < part.shoulder.x ? -1 : 1; flag.y = part.hand.y < part.shoulder.y ? -1 : 1; exceededPart = null; const rearrangePX = 3; moveBodyTo({ x: body.x + rearrangePX * flag.x + BODY.WIDTH / 2, y: body.y + rearrangePX * flag.y + BODY.HEIGHT / 2, }); }
-
After a limb is extended and the pointerup event occurs, execute the rearrangeBody function to slightly bend the extended part, allowing the next drag action to occur.
This game includes animation effects caused by gravity (arms/legs falling downward, falling when releasing both hands).
To display these smoothly, I initially used setInterval()
, but due to some differences, I switched to requestAnimationFrame()
.
setInterval()
can set the number of calls per second by passing an argument.rAF()
determines the number of executions per second based on the browser’s resources and the computer’s CPU performance (default 60 FPS).
- When creating animations with
setInterval()
, just set the func and delay.setInterval(func, delay);
- For
rAF()
, to run the animation, the callback ofrAF()
needs to callrAF()
again inside.requestAnimationFrame(render); function render() { ... requestAnimationFrame(render); }
setInterval()
returns a unique id value, so passing that id value toclearInterval()
stops it.rAF()
also returns a unique id value, so passing that id value tocancelAnimationFrame()
stops it.
- Animations implemented with
setInterval()
may have slight frame skipping or missing frames. rAF()
is optimized for animations, so it runs at an appropriate frame rate regardless of the animation environment, and solves the problem ofsetInterval()
running even when the tab is not active or the animation is outside the page.
When multiple tabs are open and the current web page is inactive,
setInterval()
continues to execute every time it is called in the background,rAF()
is called only when the screen repaints, so it does not execute in the background and waits.
- If you give animation commands before repainting finishes, the animation does not proceed smoothly as desired.
If you put the animation to apply after the repaint is finished by putting it in the callback ofrequestAnimationFrame()
, natural animations are created. - Unlike
setInterval()
, animations are called in sync with the frame creation’s initial stage, allowing for smoother operations.
- React
- React router
- Redux-toolkit
- Styled Components
- Pixi.js
- ESLint
- Jest
- Node.js
- Express
- MongoDB Atlas / Mongoose
- ESLint
- Performance
- It only includes features related to WebGL 2D rendering, making it extremely fast and lightweight.
- Cross-platform Compatibility
- Designed to operate smoothly across various platforms and devices.
- Ease of Use
- Provides an intuitive and simple API.
- The official documentation is well-organized and rich in examples.
- Schema Flexibility
- Offers more flexible schemas compared to SQL, capable of handling various data types and structures.
- Without a fixed structure, it can be flexibly adapted when data structures are frequently added, deleted, or modified.
- Intuitive Data Model
- Stores data in documents instead of rows, which are based on
JSON
. Therefore, it's easy to understand the hierarchical structure of data without complex join operations between multiple tables.
- Stores data in documents instead of rows, which are based on
- Scale-out Structure
- Allows for horizontal scaling by distributing the database across multiple servers, thereby increasing capacity.
*Hold: Refers to stone-shaped holds attached to the wall that can be grabbed or stepped on by hands and feet.
- All objects on the light blue background are holds that can be grabbed or stepped on.
- Users can drag and move the player’s hands/feet/torso.
- If the player’s hands/feet are dragged and placed on a hold, they are fixed. Otherwise, the hands/feet fall downward.
- If the center of gravity’s x-coordinate is not between both feet, the center of gravity is pulled down by gravity until one arm is extended.
- If the hands/feet fall off the holds or the center of gravity is not between both feet, it is considered that HP (health points) are consumed significantly, so HP decreases rapidly.
- If both hands grab the TOP hold, it is a complete climb (success) and the record is registered in the ranking information.
- Rankings are sorted in ascending order of climbing time. If the times are the same, the person with more remaining HP has a higher rank.
- Week 1: Planning and Design
- Weeks 2-3: Feature Development
- Week 4: Writing Test Code, Presentation
Clicking the thumbnail takes you to a YouTube link of the game demonstration video.
🔽 First Climbing Route
This was my first time making a game with the Canvas API
. Also, there were no similar projects to reference during feature development, and implementing functions purely based on my logic without libraries for joint movements and the physics engine was not easy. Nevertheless, considering the reusability of functions and step-by-step logic construction, solving unfamiliar problems was a rewarding experience.
I really love climbing. Especially, the sense of accomplishment when succeeding in a route that seemed impossible becomes a part of the driving force of my life.
I hope that through this game, others can also experience the joy of achieving goals that seem impossible.