2024 06 11 transitioning to div for parallax in game discussing code improvements - bizkite-co/handterm GitHub Wiki
original file name: Codeium Chat - Inksg3HF74f0q9t7jaztF1lGZZocVsGH.md
summary: The chat discussion revolved around transitioning from a canvas-based rendering approach to using <div>
elements for a parallax background in a game project. The user shared code snippets and sought expert advice on the architectural decision and troubleshooting the rendered output.
Codeium provided a detailed analysis focusing on factors such as performance, development complexity, flexibility, SEO, maintenance, and browser consistency. The discussion included a comparison of canvas versus <div>
elements, highlighted the benefits of simplicity and performance when using CSS for parallax effects, and suggested potential solutions for rendering issues.
The user experimented with a ParallaxLayer
component using <div>
elements styled with CSS. However, they encountered issues with the rendered output not displaying as expected. Codeium offered troubleshooting tips related to container size, image paths, CSS styles, z-index, visibility, and background positioning. Additionally, suggestions were provided on refining the CSS setup, inspecting elements using developer tools, and modifying the rendered areas in the TerminalGame
component.
Overall, the conversation covered architectural considerations, practical implementations, and debugging strategies related to transitioning to a <div>
-based parallax background in the game project.
date: 2024-06-11T20:51
Note: This is purely the output of the chat conversation and does not contain any raw data, codebase snippets, etc. used to generate the output.
User at 8:51 pm, Jun 11, 2024
I have a TerminalGame
with a couple of character that move in a <canvas>
element.
I also have a background <canvas>
element that renders some background buildings but that's not very active.
I am still having some interaction problems with the brackground <canvas>
element and I've realized that it might be a lot easier to implement and troubleshoot the background element, which is just tiled repeating elements in three layers that move slowly at different speeds to create a parallax effect, if I just render it as <div>
elements with repeating x-dimensions.
I think I've isolated the background render to this code in TerminalGame
:
this.state.layers.forEach(layer => {
drawParallaxLayer(contextBackground, layer, this.state.backgroundOffsetX, this.props.canvasWidth, this.props.canvasHeight, this.image);
});
Which calls on some existing code in .ParallaxBackground.tsx
to load these images:
export const layers: IParallaxLayer[][] = [
// Level 1 layers
[
{ imageSrc: '/images/parallax-industrial-pack/parallax-industrial-pack/layers/skill-desc_0002_far-buildings.png', scale: 0.8, movementRate: 0.4 },
{ imageSrc: '/images/parallax-industrial-pack/parallax-industrial-pack/layers/skill-desc_0001_buildings.png', scale: 0.6, movementRate: 0.6 },
{ imageSrc: '/images/parallax-industrial-pack/parallax-industrial-pack/layers/skill-desc_0000_foreground.png', scale: 0.6, movementRate: 1 },
],
// Level 2 layers
[
{ imageSrc: '/images/cyberpunk-street-files/cyberpunk-street-files/PNG/layers/far-buildings.png', scale: 0.6, movementRate: 0.4 },
{ imageSrc: '/images/cyberpunk-street-files/cyberpunk-street-files/PNG/layers/back-buildings.png', scale: 0.8, movementRate: 0.6 },
{ imageSrc: '/images/cyberpunk-street-files/cyberpunk-street-files/PNG/layers/foreground.png', scale: 1, movementRate: 1 },
],
// Add more levels as needed
];
And that depends on which game level the players are on.
The background is supposed to scroll to the left as the Hero
character runs to the right.
These are two code blocks from TerminalGame
:
updateCharacterAndBackground = (_context: CanvasRenderingContext2D, heroDx: number): number => {
const canvasCenterX = this.props.canvasWidth * this.heroXPercent;
const characterReachThreshold = canvasCenterX;
let heroResult = 0;
let newBackgroundOffsetX = this.state.backgroundOffsetX ?? 0;
// Update character position as usual
const newHeroPositionX = canvasCenterX;
// Check if the hero reaches the threshold
if (newHeroPositionX >= characterReachThreshold) {
this.isInScrollMode = true;
// Update the background offset
newBackgroundOffsetX += heroDx;
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: characterReachThreshold },
backgroundOffsetX: newBackgroundOffsetX, // Update background offset state
});
this.updateBackgroundPosition(this.state.backgroundOffsetX + heroDx);
const newX = this.state.zombie4Position.leftX - heroDx;
this.setState({ zombie4Position: { ...this.state.zombie4Position, leftX: newX } });
} else {
this.setState({ heroPosition: { ...this.state.heroPosition, leftX: newHeroPositionX } });
}
_context.clearRect(0, 0, this.props.canvasWidth, this.props.canvasHeight);
if (this.heroRef.current && _context) {
heroResult = this.heroRef.current.draw(_context, this.state.heroPosition);
}
if (this.zombie4Ref.current && _context) {
this.zombie4Ref.current.draw(_context, this.state.zombie4Position);
}
return heroResult;
}
startAnimationLoop(context: CanvasRenderingContext2D, contextBackground: CanvasRenderingContext2D) {
const frameDelay = 150; // Delay in milliseconds (100ms for 10 FPS)
let lastFrameTime = performance.now(); // use performance.now() for higher accuracy
let newX = 0;
const loop = (timestamp: number) => {
const now = performance.now();
const deltaTime = now - lastFrameTime;
if (deltaTime >= frameDelay) { // Control the frame rate
lastFrameTime = now - (deltaTime % frameDelay);
// Get the parallax layers for the current level
// Draw the parallax background layers
// context.clearRect(0, 0, this.props.canvasWidth, this.props.canvasHeight);
context.save();
this.state.layers.forEach(layer => {
drawParallaxLayer(contextBackground, layer, this.state.backgroundOffsetX, this.props.canvasWidth, this.props.canvasHeight, this.image);
});
if (this.state.isPhraseComplete) {
this.drawScrollingText(context);
}
context.restore();
// Reset globalAlpha if other drawings should not be affected
context.globalAlpha = 1.0;
newX = this.updateCharacterAndBackground(context, newX);
this.setState({ backgroundOffsetX: newX });
// Save the request ID to be able to cancel it
// this.checkProximityAndSetAction();
};
this.animationFrameIndex = requestAnimationFrame(loop);
};
// Start the animation loop
this.animationFrameIndex = requestAnimationFrame(loop);
}
The existing code from ParallaxBackground.tsx
uses a passed in <canvas>
reference, but I think a simple <div>
rendered with JSX might be a much simpler solution.
export const drawParallaxLayer = (
context: CanvasRenderingContext2D,
layer: IParallaxLayer,
offset: number,
canvasWidth: number,
canvasHeight: number,
image: HTMLImageElement
) => {
const { imageSrc, scale, movementRate } = layer;
// const image = new Image();
image.src = imageSrc;
// Wait for the image to load before drawing
image.onload = () => {
// Calculate the horizontal position based on the movement rate
const x = -offset * movementRate;
// console.log('x:', x);
// Calculate the scaled dimensions of the image
const scaledWidth = image.width * scale;
const scaledHeight = image.height * scale;
// Calculate how many times to draw the image to fill the canvas width
const numImages = Math.ceil(canvasWidth / scaledWidth) + 1;
// Draw the image as many times as needed to create the parallax effect
for (let i = 0; i <= numImages; i++) {
context.drawImage(
image,
x + i * scaledWidth % canvasWidth, // X position (looping the image)
canvasHeight - scaledHeight, // Y position (align to bottom)
scaledWidth, // Scaled width
scaledHeight // Scaled height
);
}
};
// If the image is already loaded (e.g., cached), draw it immediately
if (image.complete) {
const x = -offset * movementRate;
const scaledWidth = image.width * scale;
const scaledHeight = image.height * scale;
const numImages = Math.ceil(canvasWidth / scaledWidth);
for (let i = 0; i <= numImages; i++) {
context.drawImage(
image,
x + i * scaledWidth % canvasWidth,
canvasHeight - scaledHeight,
scaledWidth,
scaledHeight
);
}
}
};
Please provide expert commentary on this archetectural decision.
Codeium at 8:52 pm, Jun 11, 2024
Shifting from a canvas-based rendering to using <div>
elements for your parallax background is an architectural change that comes with its own set of trade-offs. Here are some considerations to help guide your decision:
- Canvas: Offers high performance for complex animations and can be more efficient for GPU rendering, particularly when working with WebGL. However, canvas can be less performant if not optimized correctly, especially with multiple layers and high-resolution images.
-
<div>
Elements: May be simpler and more performant for basic parallax effects, especially when utilizing CSS transitions or animations which are hardware accelerated. This could result in smoother animations with less CPU overhead.
- Canvas: Requires more complex JavaScript code to manage drawing and updating graphics. Debugging can be more challenging since you're dealing with a bitmap and can't inspect individual elements as you can in the DOM.
-
<div>
Elements: It's generally easier to work with since they are part of the DOM. You can inspect, style, and animate them using CSS, which can be more intuitive and less error-prone.
- Canvas: Offers a lot of flexibility as you can draw anything you like. However, this flexibility comes at the cost of increased complexity.
-
<div>
Elements: While they are easier to work with for simple scenarios, they might not be as flexible when it comes to more complex or interactive graphics.
- Canvas: Content drawn on canvas is not as accessible or SEO-friendly since it's not part of the DOM.
-
<div>
Elements: They are part of the DOM, making them more accessible and SEO-friendly.
- Canvas: Rendering is generally consistent across browsers, as it doesn't rely on the browser's CSS engine.
-
<div>
Elements: Might have slight differences in rendering across browsers due to variations in CSS engines, though this is less of an issue with modern browsers.
- Canvas: Can be harder to maintain and extend due to the complexity of the code.
-
<div>
Elements: Easier to maintain and extend, particularly for developers who are more familiar with HTML and CSS.
Given your scenario where the background is not very active and consists of tiled repeating elements with simple parallax movement, using <div>
elements styled with CSS might indeed simplify your implementation and make it easier to maintain. Additionally, offloading the parallax effect to CSS can improve performance by leveraging the browser's optimization for these kinds of animations.
Here's a simplified example of how you might implement a parallax layer using <div>
elements:
export const ParallaxLayer: React.FC<{ layer: IParallaxLayer; offset: number }> = ({ layer, offset }) => {
const xPos = -offset * layer.movementRate;
const layerStyle = {
backgroundImage: `url(${layer.imageSrc})`,
backgroundPositionX: xPos,
backgroundRepeat: 'repeat-x',
backgroundSize: `${layer.scale * 100}% auto`,
willChange: 'transform', // Hint to browsers for GPU acceleration
};
return <div className="parallax-layer" style={layerStyle} />;
};
To use this component:
<div className="parallax-background">
{this.state.layers.map((layer, index) => (
<ParallaxLayer key={index} layer={layer} offset={this.state.backgroundOffsetX} />
))}
</div>
This example is quite simplified and may need adjustments based on your specific requirements. However, it illustrates the general approach to implementing a parallax background with <div>
elements and CSS.
Ultimately, the best choice depends on your project's specific needs, your team's expertise, and long-term maintenance considerations. If you find that using <div>
elements significantly reduces complexity without sacrificing performance, it could be a good move.
User at 9:13 pm, Jun 11, 2024
I am trying the ParallaxLayer
as a proof-of-concept. I really like the simplicity.
Currently, though, I cannot see the rendered output.
One thing that is much nicer than the <canvas>
background is that I can show you the rendered HTML:
<div class="parallax-background"><div class="parallax-layer" style="background-image: url("/images/parallax-industrial-pack/parallax-industrial-pack/layers/skill-desc_0002_far-buildings.png"); background-position-x: 0px; background-repeat: repeat-x; background-size: 80%; will-change: transform; top: 0px; left: 0px;"></div><div class="parallax-layer" style="background-image: url("/images/parallax-industrial-pack/parallax-industrial-pack/layers/skill-desc_0001_buildings.png"); background-position-x: 0px; background-repeat: repeat-x; background-size: 60%; will-change: transform; top: 0px; left: 0px;"></div><div class="parallax-layer" style="background-image: url("/images/parallax-industrial-pack/parallax-industrial-pack/layers/skill-desc_0000_foreground.png"); background-position-x: 0px; background-repeat: repeat-x; background-size: 60%; will-change: transform; top: 0px; left: 0px;"></div></div>
Codeium at 9:14 pm, Jun 11, 2024
From the HTML snippet you provided, it seems like the ParallaxLayer
components are rendering, but there are a few possible reasons why you might not see them as expected:
-
Container Size: Ensure that the container
.parallax-background
has a defined height and width and is not collapsed. If it's collapsed, the backgrounddiv
s would not be visible. -
Image Paths: Confirm that the image paths are correct and the server is correctly serving the images. You can check if the images load by entering their URLs directly in the browser.
-
Z-Index and Visibility: Make sure that the
.parallax-layer
elements are not being obscured by other elements with a higherz-index
. Also, check if any parent elements havedisplay: none
orvisibility: hidden
set. -
CSS for
.parallax-background
and.parallax-layer
: Ensure that the CSS for these classes is set up correctly. For example, the.parallax-background
should have a position that allows it to contain absolute or fixed position children. The.parallax-layer
might need a position set to absolute and a width of 100% to ensure it fills the container.
Here's an example of how you might set up the CSS:
.parallax-background {
position: relative;
overflow: hidden;
height: 100vh; /* Example height, adjust as needed */
}
.parallax-layer {
position: absolute;
height: 100%;
width: 100%;
}
-
Background Position: The
backgroundPositionX
might be set to0px
initially, which would be correct if the background offset state starts at0
. However, make sure that theoffset
prop passed toParallaxLayer
changes when theHero
character moves, so that the background position gets updated. -
Browser Rendering: In some cases, the browser may not render the image if
backgroundPositionX
is not supported. Try using the shorthandbackground-position
instead.
Try applying these CSS styles and checking the listed items. If the issue persists, you may need to inspect the elements using your browser's developer tools to see what's going on. If you find that the images are not loading, check the network tab to see if the requests are being made and if they return successfully.
Lastly, since you're creating a parallax effect, you might want to modify the background-attachment
property to fixed
on your .parallax-layer
class. However, this depends on the exact effect you're aiming for.
User at 9:34 pm, Jun 11, 2024
Ok, I have cleaned out some stuff from the TerminalGame
render area:
render() {
return (
<>
<div style={{ position: "relative" }}>
<div className="parallax-background">
{this.state.layers.map((layer, index) => (
<ParallaxLayer
key={index}
layer={layer}
offset={this.state.backgroundOffsetX}
/>
))}
</div>
<canvas
style={{ position: "absolute", top: 0, left: 0 }}
ref={this.canvasRef}
width={this.props.canvasWidth}
height={this.props.canvasHeight}>
</canvas>
</div>
<Hero
ref={this.heroRef}
currentActionType={this.props.heroActionType}
scale={2}
/>
<Zombie4
ref={this.zombie4Ref}
currentActionType={this.props.zombie4ActionType}
scale={2}
/>
</>
);
}
I had been using the position: "relative"
<div>
to get the two <canvas>
elements to overlay the same area so the first one would provide a background.
Currently, I see the <canvas>
with the characters on it, fully animated.
One problem is that a lower element, an @xterm/xterm
terimal, also occupies the same area as the <canvas>
instead of immediately below it.
The other problem is that I still don't see the background images <ParallaxLayer>
Codeium at 9:34 pm, Jun 11, 2024
Given the new information and your updated render()
method in TerminalGame
, let's address both of the issues you're facing:
Since you're using position: "absolute"
on the <canvas>
element, it will be positioned relative to its first positioned (not static) ancestor element. To ensure that the @xterm/xterm
terminal appears below the <canvas>
, you'll need to adjust the layout so that the <canvas>
and the terminal are correctly stacked. Here's a possible solution:
render() {
return (
<>
<div style={{ position: "relative", height: this.props.canvasHeight }}>
<div className="parallax-background">
{this.state.layers.map((layer, index) => (
<ParallaxLayer
key={index}
layer={layer}
offset={this.state.backgroundOffsetX}
/>
))}
</div>
<canvas
style={{ position: "absolute", top: 0, left: 0, zIndex: 2 }}
ref={this.canvasRef}
width={this.props.canvasWidth}
height={this.props.canvasHeight}>
</canvas>
<Hero
ref={this.heroRef}
currentActionType={this.props.heroActionType}
scale={2}
/>
<Zombie4
ref={this.zombie4Ref}
currentActionType={this.props.zombie4ActionType}
scale={2}
/>
</div>
{/* Ensure the terminal is placed outside and below the relative div */}
<XtermTerminal /* ...props */ />
</>
);
}
Make sure to replace <XtermTerminal /* ...props */ />
with the actual component or method you use to render the terminal.
For the background images not appearing, you'll need to ensure a few things:
-
The
.parallax-background
class should have a defined height and width (or be implicitly defined by its content), and it should not be hidden by overflow settings. You can also setz-index
to ensure it's below the canvas and other content. -
The
ParallaxLayer
component should render the background images correctly. Double-check thebackground-size
,background-position
, andbackground-repeat
CSS properties. You might want to setbackground-size
tocover
or a specific pixel value instead of a percentage if the images are not appearing as expected. -
Make sure the images are loaded and accessible by the browser. You can check your server's network requests to see if the images are being fetched without errors.
-
The
top
andleft
CSS properties in the inline styles of the.parallax-layer
elements are set to0px
. If these properties are necessary, they should also be applied in yourParallaxLayer
component or CSS.
If adjustments to the layout and CSS do not resolve the issue, please inspect the elements in your browser's developer tools to check for any styles that may be causing the background to be hidden or not displayed correctly. It's also a good idea to check the console for any errors that might indicate problems with loading the images or other issues.
User at 10:02 pm, Jun 11, 2024
OK, the background images are showing up now, but I need them to be scaled down to fit in the this.props.canvasHeight
, and I need them to be a percentage of that canvas height. They should be scaled proportionally, and then repeat-x
.
I don't know how to do that. What is the best way?
Codeium at 10:02 pm, Jun 11, 2024
To ensure the background images are scaled down proportionally to fit the this.props.canvasHeight
, you can use CSS. Since you want to scale the images based on the height of the container while preserving their aspect ratios, we can use the background-size
property with a combination of auto
and a percentage value.
The percentage value will be relative to the container's width, so we'll need to calculate the correct percentage that will make the image's height equal to the desired percentage of this.props.canvasHeight
. Here's an approach to do this:
- Set the
background-size
property toauto {percentage}%
to preserve the aspect ratio and scale the height. - Use the
height
property of.parallax-layer
class to set the layers' heights to a percentage ofcanvasHeight
.
You'll need to calculate the percentage based on the image's original aspect ratio and the desired height relative to canvasHeight
. Here's how you can do it:
export const ParallaxLayer: React.FC<{ layer: IParallaxLayer; offset: number; canvasHeight: number }> = ({ layer, offset, canvasHeight }) => {
const imageHeightToCanvasHeightRatio = layer.desiredHeightPercentage / 100;
const backgroundHeight = canvasHeight * imageHeightToCanvasHeightRatio; // e.g., 50% of canvasHeight
const scale = backgroundHeight / layer.originalHeight; // assuming layer.originalHeight is the unscaled height of the image
const backgroundSize = `auto ${scale * 100}%`; // Scale height to desired percentage of canvasHeight, width auto to keep aspect ratio
const layerStyle = {
backgroundImage: `url(${layer.imageSrc})`,
backgroundPositionX: -offset * layer.movementRate,
backgroundRepeat: 'repeat-x',
backgroundSize: backgroundSize,
willChange: 'transform',
height: `${layer.desiredHeightPercentage}%`, // Set to desired height relative to the canvas height
width: '100%',
position: 'absolute',
top: canvasHeight * (1 - imageHeightToCanvasHeightRatio), // Adjust top position if needed
};
return <div className="parallax-layer" style={layerStyle} />;
};
You need to pass canvasHeight
to ParallaxLayer
and set the desiredHeightPercentage
for each layer
object, which represents the percentage of canvasHeight
that the image should cover. Also, make sure you have the original height of the image (layer.originalHeight
) to calculate the correct scale.
Then, when you use ParallaxLayer
in your TerminalGame
component, pass in the canvasHeight
prop:
<ParallaxLayer
key={index}
layer={layer}
offset={this.state.backgroundOffsetX}
canvasHeight={this.props.canvasHeight}
/>
Please adjust the layer.originalHeight
and layer.desiredHeightPercentage
according to your actual image dimensions and how much of the canvas height you want them to cover. This approach should scale your background images proportionally based on the height of the canvas.
User at 10:07 pm, Jun 11, 2024
I get this error:
ype '{ backgroundImage: string; backgroundPositionX: number; backgroundRepeat: string; backgroundSize: string; willChange: string; height: string; width: string; position: string; top: number; }' is not assignable to type 'Properties<string | number, string & {}>'.
Types of property 'position' are incompatible.
Type 'string' is not assignable to type 'Position | undefined'.ts(2322)
index.d.ts(2908, 9): The expected type comes from property 'style' which is declared here on type 'DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>'
⚠ Error (TS2322) |
Type:{
backgroundImage: string;
backgroundPositionX: number;
backgroundRepeat: string;
backgroundSize: string;
willChange: string;
height: string;
width: string;
position: string;
top: number;
}
is not assignable to type Properties<string | number, string & {}>
.
Types of property position are incompatible. Type string
is not assignable to type Position | undefined
.
Codeium at 10:07 pm, Jun 11, 2024
The error message you're seeing indicates a type mismatch between the style object you're passing to the style
prop and the expected type for the style
prop, which is React.CSSProperties
.
The position
property is expecting a specific string that matches the CSS position values ("static"
, "relative"
, "absolute"
, "sticky"
, or "fixed"
), but TypeScript infers it as a general string type from your code, which leads to the error.
To resolve this, you can explicitly type the layerStyle
object as React.CSSProperties
. This way, TypeScript will know that the position
property (and others) should be treated as CSS properties, not general strings. Here's how you can modify your ParallaxLayer
component:
import React from 'react';
export const ParallaxLayer: React.FC<{ layer: IParallaxLayer; offset: number; canvasHeight: number }> = ({ layer, offset, canvasHeight }) => {
// ... your existing code ...
const layerStyle: React.CSSProperties = {
backgroundImage: `url(${layer.imageSrc})`,
backgroundPositionX: -offset * layer.movementRate,
backgroundRepeat: 'repeat-x',
backgroundSize: backgroundSize,
willChange: 'transform',
height: `${layer.desiredHeightPercentage}%`,
width: '100%',
position: 'absolute', // Ensure this matches a valid CSS position value
top: canvasHeight * (1 - imageHeightToCanvasHeightRatio), // Adjust top position if needed
};
// ... rest of your component ...
};
By typing the layerStyle
object as React.CSSProperties
, you inform TypeScript that this object will be used for inline styles, and TypeScript will enforce correct typing for CSS properties.
Make sure all the other properties in layerStyle
are assignable to their respective CSS property types. If you have other type errors after this change, you may need to adjust the types of other properties as well.
User at 10:21 pm, Jun 11, 2024
It's displaying, but I am having trouble with scale.
The layers
don't have a desiredHeightPercentage
that I think you were referring to:
{ imageSrc: '/images/parallax-industrial-pack/parallax-industrial-pack/layers/skill-desc_0002_far-buildings.png', scale: 0.8, movementRate: 0.4 },
It should use the scale
for that.
I tried to modify the code but it isn't working:
export interface IParallaxLayer {
imageSrc: string;
scale: number;
movementRate: number;
desiredHeightPercentage: number;
originalHeight: number;
}
export const ParallaxLayer: React.FC<{ layer: IParallaxLayer; offset: number; canvasHeight: number }> = ({ layer, offset, canvasHeight }) => {
const imageHeightToCanvasHeightRatio = layer.scale / 100;
const backgroundHeight = canvasHeight * imageHeightToCanvasHeightRatio; // e.g., 50% of canvasHeight
const scale = backgroundHeight / layer.originalHeight; // assuming layer.originalHeight is the unscaled height of the image
const backgroundSize = `auto ${scale * 100}%`; // Scale height to desired percentage of canvasHeight, width auto to keep aspect ratio
const layerStyle: React.CSSProperties = {
backgroundImage: `url(${layer.imageSrc})`,
backgroundPositionX: -offset * layer.movementRate,
backgroundRepeat: 'repeat-x',
backgroundSize: backgroundSize,
willChange: 'transform',
height: `${layer.scale}%`, // Set to desired height relative to the canvas height
width: '100%',
position: 'absolute',
top: canvasHeight * (1 - imageHeightToCanvasHeightRatio), // Adjust top position if needed
};
return <div className="parallax-layer" style={layerStyle} />;
};
I think it pushes the image off the bottom of the <div>
Codeium at 10:21 pm, Jun 11, 2024
I understand the confusion. Let's correct the calculation so that the scale
field from your IParallaxLayer
is used to determine the height of the parallax layer as a percentage of the canvas height. Since scale
is a number representing the percentage (e.g., 0.8 for 80%), you can use it directly to set the height of the layer.
Here's how to adjust the ParallaxLayer
component:
export const ParallaxLayer: React.FC<{ layer: IParallaxLayer; offset: number; canvasHeight: number }> = ({ layer, offset, canvasHeight }) => {
// Calculate the height of the background as a percentage of the canvas height
const backgroundHeight = canvasHeight * layer.scale; // Now scale is a direct multiplier
const layerStyle: React.CSSProperties = {
backgroundImage: `url(${layer.imageSrc})`,
backgroundPositionX: -offset * layer.movementRate,
backgroundRepeat: 'repeat-x',
backgroundSize: `auto ${backgroundHeight}px`, // Use pixel value for height to match the canvasHeight scaling
willChange: 'transform',
height: `${backgroundHeight}px`, // Use pixel value for the height
width: '100%',
position: 'absolute',
top: 0, // Align to the top of the container
};
return <div className="parallax-layer" style={layerStyle} />;
};
In this updated code, backgroundSize
is set to auto {backgroundHeight}px
, which means the width will automatically adjust to maintain the image's aspect ratio, and the height will be a pixel value that is the scaled percentage of canvasHeight
.
The height
is also set using a pixel value (backgroundHeight
), which is the scaled percentage of canvasHeight
. This ensures the layer's height matches the scaled height of the background image.
The top
property is set to 0
to align the layer to the top of the container. If you need to vertically center or align the layers differently, you may adjust the top
property accordingly.
Since you are using TypeScript, ensure that the originalHeight
and desiredHeightPercentage
properties are removed from the IParallaxLayer
interface if they are not being used:
export interface IParallaxLayer {
imageSrc: string;
scale: number; // This is a multiplier (e.g., 0.8 for 80%)
movementRate: number;
}
This updated component should scale the background images to fit within the canvasHeight
, maintaining their aspect ratios and repeating along the x-axis.
User at 10:55 pm, Jun 11, 2024
Ok, I think it is all working now, except the background is not scrolling to the left when the player runs to the right.
It is moving a little bit to the left, and it looks like the parallax is working, but then it stops and I think it might even move back.
Do we need to update a long-lasting ref in the TerminalGame
which will be the offset number?
This is the current TerminalGame
:
// TerminalGame.ts
import React, { TouchEventHandler } from 'react';
import { Zombie4 } from './Zombie4';
import { Hero } from './Hero';
import { Action, ActionType } from './types/ActionTypes';
import { SpritePosition } from './types/Position';
import { layers } from './Level';
import { Sprite } from './sprites/Sprite';
import { IParallaxLayer, ParallaxLayer } from './ParallaxLayer';
interface ITerminalGameProps {
canvasHeight: number
canvasWidth: number
isInPhraseMode: boolean
heroActionType: ActionType
zombie4ActionType: ActionType
onTouchStart: TouchEventHandler<HTMLDivElement>;
onTouchEnd: TouchEventHandler<HTMLDivElement>;
}
interface ITerminalGameState {
currentLevel: number;
heroActionType: ActionType;
heroPosition: SpritePosition;
heroReady: boolean;
zombie4ActionType: ActionType;
zombie4Position: SpritePosition;
zombie4Ready: boolean;
context: CanvasRenderingContext2D | null;
contextBackground: CanvasRenderingContext2D | null;
idleStartTime: number | null; // in milliseconds
backgroundOffsetX: number;
isPhraseComplete: boolean;
textScrollX: number;
layers: IParallaxLayer[];
}
interface ICharacterRefMethods {
getCurrentSprite: () => Sprite | null;
getActions: () => Record<ActionType, Action>;
draw: (context: CanvasRenderingContext2D, position: SpritePosition) => number;
}
export class TerminalGame extends React.Component<ITerminalGameProps, ITerminalGameState> {
private canvasRef = React.createRef<HTMLCanvasElement>();
private gameTime: number = 0;
private animationFrameIndex?: number;
public context: CanvasRenderingContext2D | null = null;
private heroXPercent: number = 0.23;
isInScrollMode: boolean = true;
zombie4DeathTimeout: NodeJS.Timeout | null = null;
// Add a new field for the text animation
private textScrollX: number = this.props.canvasWidth; // Start offscreen to the right
private textToScroll: string = "Terminal Velocity!";
private heroRef = React.createRef<ICharacterRefMethods>();
private zombie4Ref = React.createRef<ICharacterRefMethods>();
private image = new Image();
getInitState(props: ITerminalGameProps): ITerminalGameState {
return {
currentLevel: 1,
heroActionType: props.heroActionType,
heroPosition: { leftX: props.canvasWidth * this.heroXPercent, topY: 30 },
heroReady: false,
zombie4ActionType: props.zombie4ActionType,
zombie4Position: { leftX: 50, topY: 0 },
zombie4Ready: false,
context: null as CanvasRenderingContext2D | null,
contextBackground: null as CanvasRenderingContext2D | null,
idleStartTime: null,
backgroundOffsetX: 0,
isPhraseComplete: false,
textScrollX: this.props.canvasWidth,
layers: layers[0]
};
}
public getLevel = () => {
return this.state.currentLevel;
}
public setLevel = (newLevel: number) => {
const newLayers = layers[newLevel - 1];
this.setState({
currentLevel: newLevel,
layers: newLayers
});
}
public resetGame(): void {
// TODO: Handle addListeners or subscrition before resetting state.
// this.setState(this.getInitstate(this.props));
}
changeLevel = (newLevel: number) => {
this.setState({ currentLevel: newLevel });
}
constructor(props: ITerminalGameProps) {
super(props);
this.state = this.getInitState(props);
}
componentDidMount() {
const canvas = this.canvasRef.current;
if (canvas) {
const context = canvas.getContext('2d');
if (context) {
this.setupCanvas(context.canvas);
}
}
}
componentDidUpdate(
_prevProps: Readonly<ITerminalGameProps>,
prevState: Readonly<ITerminalGameState>,
_snapshot?: any
): void {
if (this.state.currentLevel !== prevState.currentLevel) {
}
// Ensure no animation loop is already running
if (!this.animationFrameIndex) {
this.startAnimationLoop(this.state.context!, this.state.contextBackground!);
}
}
componentWillUnmount(): void {
if (this.animationFrameIndex) {
cancelAnimationFrame(this.animationFrameIndex);
}
this.stopAnimationLoop();
if (this.zombie4DeathTimeout) {
clearTimeout(this.zombie4DeathTimeout);
}
}
// Call this method to update the background position
updateBackgroundPosition(newOffsetX: number) {
this.setState({ backgroundOffsetX: newOffsetX });
// console.log("backgroundOffsetX: ", this.state.backgroundOffsetX, "newOffsetX: ", newOffsetX);
}
setupCanvas(canvas: HTMLCanvasElement) {
if (canvas) {
const context = canvas.getContext('2d');
if (
context instanceof CanvasRenderingContext2D
) {
// Set the context in the state instead of a class property
this.setState({ context: context });
// Load background images
} else {
console.error("Obtained context is not a CanvasRenderingContext2D instance.");
}
} else {
console.error("Failed to get canvas element.");
}
}
drawScrollingText(context: CanvasRenderingContext2D) {
context.font = 'italic 60px Arial'; // Customize as needed
context.fillStyle = 'lightgreen'; // Text color
context.fillText(this.textToScroll, this.textScrollX, 85); // Adjust Y coordinate as needed
// Update the X position for the next frame
this.textScrollX -= 5; // Adjust speed as needed
// Reset text position if it's fully offscreen to the left
if (this.textScrollX < -context.measureText(this.textToScroll).width) {
this.textScrollX = this.props.canvasWidth;
}
}
// Call this method when the game is completed
public completeGame() {
this.setZombie4ToDeathThenResetPosition();
this.textScrollX = this.props.canvasWidth; // Reset scroll position
this.setState({
isPhraseComplete: true,
textScrollX: this.props.canvasWidth
});
}
public setZombie4ToDeathThenResetPosition = (): void => {
// Set the zombie action to 'Death'
if (this.zombie4DeathTimeout) {
clearTimeout(this.zombie4DeathTimeout);
this.zombie4DeathTimeout = null;
}
this.setZombie4Action('Death');
// After three seconds, reset the position
this.zombie4DeathTimeout = setTimeout(() => {
this.setState({
zombie4Position: { topY: 0, leftX: -70 },
isPhraseComplete: false,
textScrollX: this.props.canvasWidth
});
// Optionally reset the action if needed
this.setZombie4Action('Walk'); // Or the default action you want to set
this.zombie4DeathTimeout = null;
}, 5000);
};
checkProximityAndSetAction() {
// Constants to define "near"
// TODO: Sprite image widths seem to be off by about 150.
const ATTACK_THRESHOLD = 250; // pixels or appropriate unit for your game
// Calculate the distance between the hero and zombie4
const distance = this.state.heroPosition.leftX - this.state.zombie4Position.leftX;
// If zombie4 is near the Hero, set its current action to Attack
if (150 < distance && distance < ATTACK_THRESHOLD) {
// Assuming zombie4 has a method to update its action
this.setZombie4Action('Attack'); // Replace 'Attack' with actual ActionType for attacking
} else {
// Otherwise, set it back to whatever action it should be doing when not attacking
if (this.state.zombie4ActionType === 'Attack') {
this.setZombie4Action('Walk'); // Replace 'Walk' with actual ActionType for walking
}
}
// Update the state or force a re-render if necessary, depending on how your animation loop is set up
// this.setState({ ... }); or this.forceUpdate();
}
setZombie4Action(action: ActionType) {
this.setState({ zombie4ActionType: action });
}
updateCharacterAndBackground = (_context: CanvasRenderingContext2D, heroDx: number): number => {
const canvasCenterX = this.props.canvasWidth * this.heroXPercent;
const characterReachThreshold = canvasCenterX;
let heroResult = 0;
let newBackgroundOffsetX = this.state.backgroundOffsetX ?? 0;
// Update character position as usual
const newHeroPositionX = canvasCenterX;
// Check if the hero reaches the threshold
if (newHeroPositionX >= characterReachThreshold) {
this.isInScrollMode = true;
// Update the background offset
newBackgroundOffsetX += heroDx;
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: characterReachThreshold },
backgroundOffsetX: newBackgroundOffsetX, // Update background offset state
});
this.updateBackgroundPosition(this.state.backgroundOffsetX + heroDx);
const newX = this.state.zombie4Position.leftX - heroDx;
this.setState({ zombie4Position: { ...this.state.zombie4Position, leftX: newX } });
} else {
this.setState({ heroPosition: { ...this.state.heroPosition, leftX: newHeroPositionX } });
}
_context.clearRect(0, 0, this.props.canvasWidth, this.props.canvasHeight);
if (this.heroRef.current && _context) {
heroResult = this.heroRef.current.draw(_context, this.state.heroPosition);
}
if (this.zombie4Ref.current && _context) {
this.zombie4Ref.current.draw(_context, this.state.zombie4Position);
}
return heroResult;
}
startAnimationLoop(context: CanvasRenderingContext2D, contextBackground: CanvasRenderingContext2D) {
const frameDelay = 150; // Delay in milliseconds (100ms for 10 FPS)
let lastFrameTime = performance.now(); // use performance.now() for higher accuracy
let newX = 0;
const loop = (timestamp: number) => {
const now = performance.now();
const deltaTime = now - lastFrameTime;
if (deltaTime >= frameDelay) { // Control the frame rate
lastFrameTime = now - (deltaTime % frameDelay);
// Get the parallax layers for the current level
if (this.state.isPhraseComplete) {
this.drawScrollingText(context);
}
newX = this.updateCharacterAndBackground(context, newX);
this.setState({ backgroundOffsetX: newX });
// Save the request ID to be able to cancel it
this.checkProximityAndSetAction();
};
this.animationFrameIndex = requestAnimationFrame(loop);
};
// Start the animation loop
this.animationFrameIndex = requestAnimationFrame(loop);
}
// Call this method when both characters are ready to draw
maybeStartAnimationLoop() {
if (this.state.context && this.state.contextBackground) {
this.startAnimationLoop(this.state.context, this.state.contextBackground);
}
}
stopAnimationLoop() {
if (this.animationFrameIndex) {
cancelAnimationFrame(this.animationFrameIndex);
this.animationFrameIndex = undefined;
console.log("stopAnimationLoop");
}
}
handleHeroPositionChange = (newPosition: SpritePosition) => {
this.setState({ heroPosition: newPosition });
};
handleZombie4PositionChange = (newPosition: SpritePosition) => {
this.setState({ zombie4Position: newPosition });
}
// Additional methods for calculating WPM, updating the progress bar, etc.
render() {
return (
<>
<div style={{ position: "relative", height: this.props.canvasHeight }}>
<div className="parallax-background">
{this.state.layers.map((layer, index) => (
<ParallaxLayer
key={index}
layer={layer}
offset={this.state.backgroundOffsetX}
canvasHeight={this.props.canvasHeight}
/>
))}
</div>
<canvas
style={{ position: "absolute", top: 0, left: 0, zIndex: 2 }}
ref={this.canvasRef}
width={this.props.canvasWidth}
height={this.props.canvasHeight}>
</canvas>
<Hero
ref={this.heroRef}
currentActionType={this.props.heroActionType}
scale={2}
/>
<Zombie4
ref={this.zombie4Ref}
currentActionType={this.props.zombie4ActionType}
scale={2}
/>
</div>
</>
);
}
}
Codeium at 10:56 pm, Jun 11, 2024
The key to making the background scroll is to ensure that the backgroundOffsetX
state is continuously updated as the hero character moves. From your TerminalGame
component, it looks like you're attempting to do this in the updateCharacterAndBackground
method. However, there might be a problem with how the newX
variable is being handled and updated.
Your startAnimationLoop
method initializes newX
to 0 every frame, which could be causing the issue. Instead, newX
should be initialized outside of the loop
function so that its value persists between frames.
Here's the revised startAnimationLoop
method:
// ... rest of your class ...
startAnimationLoop(context: CanvasRenderingContext2D, contextBackground: CanvasRenderingContext2D) {
const frameDelay = 150; // Delay in milliseconds (100ms for 10 FPS)
let lastFrameTime = performance.now(); // Use performance.now() for higher accuracy
// Initialize newX outside the loop so its value persists across frames
let newX = this.state.backgroundOffsetX;
const loop = (timestamp: number) => {
const now = performance.now();
const deltaTime = now - lastFrameTime;
if (deltaTime >= frameDelay) { // Control the frame rate
lastFrameTime = now - (deltaTime % frameDelay);
// Get the parallax layers for the current level
if (this.state.isPhraseComplete) {
this.drawScrollingText(context);
}
// Update character and background positions
newX = this.updateCharacterAndBackground(context, newX);
// Update the state with the new background offset
this.setState({ backgroundOffsetX: newX });
// Check proximity and set action for other characters like zombie4
this.checkProximityAndSetAction();
}
// Request the next frame of the animation
this.animationFrameIndex = requestAnimationFrame(loop);
};
// Start the animation loop
this.animationFrame<number>(loop);
}
// ... rest of your class ...
In this revision, newX
is declared outside the loop
function, allowing its updated value to be carried over to the next frame. Now, updateCharacterAndBackground
should be adjusting the background offset correctly as the hero character moves.
Another thing to note is that in the updateCharacterAndBackground
method, you're calling this.updateBackgroundPosition
with this.state.backgroundOffsetX + heroDx
, but then immediately afterwards, you're setting backgroundOffsetX
with newBackgroundOffsetX
. This seems redundant and might be causing the issue.
Here's a simplified version without the redundant call:
updateCharacterAndBackground = (_context: CanvasRenderingContext2D, heroDx: number): number => {
// ... your existing logic ...
// Check if the hero reaches the threshold
if (newHeroPositionX >= characterReachThreshold) {
this.isInScrollMode = true;
// Update the background offset
newBackgroundOffsetX += heroDx;
// Update the hero and zombie positions
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: characterReachThreshold },
backgroundOffsetX: newBackgroundOffsetX,
zombie4Position: { ...this.state.zombie4Position, leftX: this.state.zombie4Position.leftX - heroDx },
});
} else {
this.setState({ heroPosition: { ...this.state.heroPosition, leftX: newHeroPositionX } });
}
// ... rest of your method ...
}
Make sure that heroDx
is positive when the hero is moving to the right, so that the background moves left (negative direction). Also, ensure that heroDx
is being calculated correctly based on the hero's movement speed and potentially the elapsed time between frames for smooth animation.
User at 6:43 am, Jun 12, 2024
If I use that, where would I put a log to see that the background offset is being incremented and saved? I should be able to see a number that increases as the Hero
runs to the right.
Codeium at 6:43 am, Jun 12, 2024
To log the background offset and see that it's being incremented and saved, you can add a console.log
statement right after you update the state with the new backgroundOffsetX
in the updateCharacterAndBackground
method.
Here's where you could place the log:
updateCharacterAndBackground = (_context: CanvasRenderingContext2D, heroDx: number): number => {
// ... your existing logic ...
// Check if the hero reaches the threshold
if (newHeroPositionWidth >= characterReachThreshold) {
this.isInScrollMode = true;
// Update the background offset
newBackgroundOffsetX += heroDx;
// Update the hero and zombie positions
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: characterReachThreshold },
backgroundOffsetX: newBackgroundOffsetX,
zombie4Position: { ...this.state.zombie4Position, leftX: this.state.zombie4Position.leftX - heroDx },
}, () => {
// Log the new background offset after the state has been updated
console.log("New backgroundOffsetX:", this.state.backgroundOffsetX);
});
} else {
this.setState({ heroPosition: { ...this.state.heroPosition, leftX: newHeroPositionX } });
}
// ... rest of your method ...
}
By placing the console.log
inside the callback of the setState
method, you ensure that the log statement runs after the state has been updated. This gives you the most accurate reading of the backgroundOffsetX
at the time it's logged.
Keep in mind that excessive logging can affect performance, especially if the updateCharacterAndBackground
method is being called rapidly (such as in an animation loop). So once you've confirmed that the background offset is updating as expected, you should remove or comment out the console.log
to avoid potential performance issues.
User at 6:53 am, Jun 12, 2024
When I type, I get this.
70TerminalGame.tsx:271 New backgroundOffsetX: 0
9TerminalGame.tsx:271 New backgroundOffsetX: 2
15TerminalGame.tsx:271 New backgroundOffsetX: 0
11TerminalGame.tsx:271 New backgroundOffsetX: 2
35TerminalGame.tsx:271 New backgroundOffsetX: 0
The parent HandTerm
component changes the Hero
action to Run
with a dx
of 2 for a certain number of milliseconds, and then reverts to Idle
with a dx
of 0.
This is the current TerminalGame
code:
updateCharacterAndBackground = (_context: CanvasRenderingContext2D, heroDx: number): number => {
const canvasCenterX = this.props.canvasWidth * this.heroXPercent;
const characterReachThreshold = canvasCenterX;
let heroResult = 0;
let newBackgroundOffsetX = this.state.backgroundOffsetX ?? 0;
// Update character position as usual
const newHeroPositionX = canvasCenterX;
_context.clearRect(0, 0, this.props.canvasWidth, this.props.canvasHeight);
if (this.heroRef.current && _context) {
heroResult = this.heroRef.current.draw(_context, this.state.heroPosition);
}
if (this.zombie4Ref.current && _context) {
this.zombie4Ref.current.draw(_context, this.state.zombie4Position);
}
// Check if the hero reaches the threshold
if (newHeroPositionX >= characterReachThreshold) {
this.isInScrollMode = true;
// Update the background offset
newBackgroundOffsetX += heroDx;
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: characterReachThreshold },
backgroundOffsetX: newBackgroundOffsetX, // Update background offset state
}, () => {
// Log the new background offset after the state has been updated
console.log("New backgroundOffsetX:", this.state.backgroundOffsetX);
});
this.updateBackgroundPosition(this.state.backgroundOffsetX + heroDx);
const newX = this.state.zombie4Position.leftX - heroDx;
this.setState({ zombie4Position: { ...this.state.zombie4Position, leftX: newX } });
} else {
this.setState({ heroPosition: { ...this.state.heroPosition, leftX: newHeroPositionX } });
}
return heroResult;
}
startAnimationLoop(context: CanvasRenderingContext2D, contextBackground: CanvasRenderingContext2D) {
const frameDelay = 150; // Delay in milliseconds (100ms for 10 FPS)
let lastFrameTime = performance.now(); // use performance.now() for higher accuracy
// Initialize newX outside the loop so its value persists across frames
let newX = this.state.backgroundOffsetX;
const loop = (timestamp: number) => {
const now = performance.now();
const deltaTime = now - lastFrameTime;
if (deltaTime >= frameDelay) { // Control the frame rate
lastFrameTime = now - (deltaTime % frameDelay);
// Get the parallax layers for the current level
if (this.state.isPhraseComplete) {
this.drawScrollingText(context);
}
newX = this.updateCharacterAndBackground(context, newX);
this.setState({ backgroundOffsetX: newX });
// Save the request ID to be able to cancel it
this.checkProximityAndSetAction();
};
this.animationFrameIndex = requestAnimationFrame(loop);
};
// Start the animation loop
this.animationFrameIndex = requestAnimationFrame(loop);
}
Codeium at 6:54 am, Jun 12, 2024
The log you're seeing indicates that your backgroundOffsetX
is getting reset to 0 intermittently. This can happen if there are conflicting state updates, especially given that setState
is asynchronous. The issue could be due to the multiple calls to setState
that affect backgroundOffsetX
in the updateCharacterAndBackground
method.
Let's streamline your updateCharacterAndBackground
method to ensure that backgroundOffsetX
is updated in a single call to setState
which should avoid the conflicting state updates:
updateCharacterAndBackground = (_context: CanvasRenderingContext2D, heroDx: number): number => {
const canvasCenterX = this.props.canvasWidth * this.heroXPercent;
const characterReachThreshold = canvasCenterX;
// Clear the canvas for new drawing
_context.clearRect(0, 0, this.props.canvasWidth, this.props.canvasHeight);
// Draw the hero and zombie4
const heroResult = this.heroRef.current?.draw(_context, this.state.heroPosition) ?? 0;
this.zombie4Ref.current?.draw(_context, this.state.zombie4Position);
// Calculate the new position for zombie4 based on the hero's movement
const newZombie4Position = { ...this.state.zombie4Position, leftX: this.state.zombie4Position.leftX - heroDx };
// Determine if we need to scroll the background
if (this.isInScrollMode) {
// Calculate the new background offset
const newBackgroundOffsetX = this.state.backgroundOffsetX + heroDx;
// Update the state with the new hero position, zombie4 position, and background offset
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: characterReachThreshold },
zombie4Position: newZombie4Position,
backgroundOffsetX: newBackgroundOffsetX,
}, () => {
// Log the new background offset after the state has been updated
console.log("New backgroundOffsetX:", this.state.backgroundOffsetX);
});
} else {
// The hero is not in scroll mode, so we only update the hero's position
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: canvasCenterX },
});
}
return heroResult;
}
In this updated code, we ensure that there's only one call to setState
where we update the hero position, zombie4 position, and backgroundOffsetX
synchronously. This should ensure that backgroundOffsetX
is updated correctly without being reset.
Additionally, ensure that isInScrollMode
is set correctly. If it's intended to be determined within this method rather than set elsewhere, you would need to have logic to set it based on whether the hero has reached a certain threshold.
Also, it appears that the method this.updateBackgroundPosition
is being called unnecessarily since you're already updating backgroundOffsetX
in the setState
call. You can remove that call unless it has some other side effects that are not visible in the provided code snippet.
User at 7:11 am, Jun 12, 2024
I am still getting this:
17TerminalGame.tsx:266 New backgroundOffsetX: 2
4751TerminalGame.tsx:266 New backgroundOffsetX: 0
12TerminalGame.tsx:266 New backgroundOffsetX: 2
186TerminalGame.tsx:266 New backgroundOffsetX: 0
Here is the current TerminalGame
.
// TerminalGame.ts
import React, { TouchEventHandler } from 'react';
import { Zombie4 } from './Zombie4';
import { Hero } from './Hero';
import { Action, ActionType } from './types/ActionTypes';
import { SpritePosition } from './types/Position';
import { layers } from './Level';
import { Sprite } from './sprites/Sprite';
import { IParallaxLayer, ParallaxLayer } from './ParallaxLayer';
interface ITerminalGameProps {
canvasHeight: number
canvasWidth: number
isInPhraseMode: boolean
heroActionType: ActionType
zombie4ActionType: ActionType
onTouchStart: TouchEventHandler<HTMLDivElement>;
onTouchEnd: TouchEventHandler<HTMLDivElement>;
}
interface ITerminalGameState {
currentLevel: number;
heroActionType: ActionType;
heroPosition: SpritePosition;
heroReady: boolean;
zombie4ActionType: ActionType;
zombie4Position: SpritePosition;
zombie4Ready: boolean;
context: CanvasRenderingContext2D | null;
contextBackground: CanvasRenderingContext2D | null;
idleStartTime: number | null; // in milliseconds
backgroundOffsetX: number;
isPhraseComplete: boolean;
textScrollX: number;
layers: IParallaxLayer[];
}
interface ICharacterRefMethods {
getCurrentSprite: () => Sprite | null;
getActions: () => Record<ActionType, Action>;
draw: (context: CanvasRenderingContext2D, position: SpritePosition) => number;
}
export class TerminalGame extends React.Component<ITerminalGameProps, ITerminalGameState> {
private canvasRef = React.createRef<HTMLCanvasElement>();
private gameTime: number = 0;
private animationFrameIndex?: number;
public context: CanvasRenderingContext2D | null = null;
private heroXPercent: number = 0.23;
isInScrollMode: boolean = true;
zombie4DeathTimeout: NodeJS.Timeout | null = null;
// Add a new field for the text animation
private textScrollX: number = this.props.canvasWidth; // Start offscreen to the right
private textToScroll: string = "Terminal Velocity!";
private heroRef = React.createRef<ICharacterRefMethods>();
private zombie4Ref = React.createRef<ICharacterRefMethods>();
private image = new Image();
getInitState(props: ITerminalGameProps): ITerminalGameState {
return {
currentLevel: 1,
heroActionType: props.heroActionType,
heroPosition: { leftX: props.canvasWidth * this.heroXPercent, topY: 30 },
heroReady: false,
zombie4ActionType: props.zombie4ActionType,
zombie4Position: { leftX: 50, topY: 0 },
zombie4Ready: false,
context: null as CanvasRenderingContext2D | null,
contextBackground: null as CanvasRenderingContext2D | null,
idleStartTime: null,
backgroundOffsetX: 0,
isPhraseComplete: false,
textScrollX: this.props.canvasWidth,
layers: layers[0]
};
}
public getLevel = () => {
return this.state.currentLevel;
}
public setLevel = (newLevel: number) => {
const newLayers = layers[newLevel - 1];
this.setState({
currentLevel: newLevel,
layers: newLayers
});
}
public resetGame(): void {
// TODO: Handle addListeners or subscrition before resetting state.
// this.setState(this.getInitstate(this.props));
}
changeLevel = (newLevel: number) => {
this.setState({ currentLevel: newLevel });
}
constructor(props: ITerminalGameProps) {
super(props);
this.state = this.getInitState(props);
}
componentDidMount() {
const canvas = this.canvasRef.current;
if (canvas) {
const context = canvas.getContext('2d');
if (context) {
this.setupCanvas(context.canvas);
}
}
}
componentDidUpdate(
_prevProps: Readonly<ITerminalGameProps>,
prevState: Readonly<ITerminalGameState>,
_snapshot?: any
): void {
if (this.state.currentLevel !== prevState.currentLevel) {
}
// Ensure no animation loop is already running
if (!this.animationFrameIndex) {
this.startAnimationLoop(this.state.context!, this.state.contextBackground!);
}
}
componentWillUnmount(): void {
if (this.animationFrameIndex) {
cancelAnimationFrame(this.animationFrameIndex);
}
this.stopAnimationLoop();
if (this.zombie4DeathTimeout) {
clearTimeout(this.zombie4DeathTimeout);
}
}
// Call this method to update the background position
updateBackgroundPosition(newOffsetX: number) {
this.setState({ backgroundOffsetX: newOffsetX });
// console.log("backgroundOffsetX: ", this.state.backgroundOffsetX, "newOffsetX: ", newOffsetX);
}
setupCanvas(canvas: HTMLCanvasElement) {
if (canvas) {
const context = canvas.getContext('2d');
if (
context instanceof CanvasRenderingContext2D
) {
// Set the context in the state instead of a class property
this.setState({ context: context });
// Load background images
} else {
console.error("Obtained context is not a CanvasRenderingContext2D instance.");
}
} else {
console.error("Failed to get canvas element.");
}
}
drawScrollingText(context: CanvasRenderingContext2D) {
context.font = 'italic 60px Arial'; // Customize as needed
context.fillStyle = 'lightgreen'; // Text color
context.fillText(this.textToScroll, this.textScrollX, 85); // Adjust Y coordinate as needed
// Update the X position for the next frame
this.textScrollX -= 5; // Adjust speed as needed
// Reset text position if it's fully offscreen to the left
if (this.textScrollX < -context.measureText(this.textToScroll).width) {
this.textScrollX = this.props.canvasWidth;
}
}
// Call this method when the game is completed
public completeGame() {
this.setZombie4ToDeathThenResetPosition();
this.textScrollX = this.props.canvasWidth; // Reset scroll position
this.setState({
isPhraseComplete: true,
textScrollX: this.props.canvasWidth
});
}
public setZombie4ToDeathThenResetPosition = (): void => {
// Set the zombie action to 'Death'
if (this.zombie4DeathTimeout) {
clearTimeout(this.zombie4DeathTimeout);
this.zombie4DeathTimeout = null;
}
this.setZombie4Action('Death');
// After three seconds, reset the position
this.zombie4DeathTimeout = setTimeout(() => {
this.setState({
zombie4Position: { topY: 0, leftX: -70 },
isPhraseComplete: false,
textScrollX: this.props.canvasWidth
});
// Optionally reset the action if needed
this.setZombie4Action('Walk'); // Or the default action you want to set
this.zombie4DeathTimeout = null;
}, 5000);
};
checkProximityAndSetAction() {
// Constants to define "near"
// TODO: Sprite image widths seem to be off by about 150.
const ATTACK_THRESHOLD = 250; // pixels or appropriate unit for your game
// Calculate the distance between the hero and zombie4
const distance = this.state.heroPosition.leftX - this.state.zombie4Position.leftX;
// If zombie4 is near the Hero, set its current action to Attack
if (150 < distance && distance < ATTACK_THRESHOLD) {
// Assuming zombie4 has a method to update its action
this.setZombie4Action('Attack'); // Replace 'Attack' with actual ActionType for attacking
} else {
// Otherwise, set it back to whatever action it should be doing when not attacking
if (this.state.zombie4ActionType === 'Attack') {
this.setZombie4Action('Walk'); // Replace 'Walk' with actual ActionType for walking
}
}
// Update the state or force a re-render if necessary, depending on how your animation loop is set up
// this.setState({ ... }); or this.forceUpdate();
}
setZombie4Action(action: ActionType) {
this.setState({ zombie4ActionType: action });
}
updateCharacterAndBackground = (_context: CanvasRenderingContext2D, heroDx: number): number => {
const canvasCenterX = this.props.canvasWidth * this.heroXPercent;
const characterReachThreshold = canvasCenterX;
// Clear the canvas for new drawing
_context.clearRect(0, 0, this.props.canvasWidth, this.props.canvasHeight);
// Draw the hero and zombie4
const heroResult = this.heroRef.current?.draw(_context, this.state.heroPosition) ?? 0;
this.zombie4Ref.current?.draw(_context, this.state.zombie4Position);
// Calculate the new position for zombie4 based on the hero's movement
const newZombie4Position = { ...this.state.zombie4Position, leftX: this.state.zombie4Position.leftX - heroDx };
// Determine if we need to scroll the background
if (this.isInScrollMode) {
// Calculate the new background offset
const newBackgroundOffsetX = this.state.backgroundOffsetX + heroDx;
// Update the state with the new hero position, zombie4 position, and background offset
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: characterReachThreshold },
zombie4Position: newZombie4Position,
backgroundOffsetX: newBackgroundOffsetX,
}, () => {
// Log the new background offset after the state has been updated
console.log("New backgroundOffsetX:", this.state.backgroundOffsetX);
});
} else {
// The hero is not in scroll mode, so we only update the hero's position
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: canvasCenterX },
});
}
return heroResult;
}
startAnimationLoop(context: CanvasRenderingContext2D, contextBackground: CanvasRenderingContext2D) {
const frameDelay = 150; // Delay in milliseconds (100ms for 10 FPS)
let lastFrameTime = performance.now(); // use performance.now() for higher accuracy
// Initialize newX outside the loop so its value persists across frames
let newX = this.state.backgroundOffsetX;
const loop = (timestamp: number) => {
const now = performance.now();
const deltaTime = now - lastFrameTime;
if (deltaTime >= frameDelay) { // Control the frame rate
lastFrameTime = now - (deltaTime % frameDelay);
// Get the parallax layers for the current level
if (this.state.isPhraseComplete) {
this.drawScrollingText(context);
}
newX = this.updateCharacterAndBackground(context, newX);
this.setState({ backgroundOffsetX: newX });
// Save the request ID to be able to cancel it
this.checkProximityAndSetAction();
};
this.animationFrameIndex = requestAnimationFrame(loop);
};
// Start the animation loop
this.animationFrameIndex = requestAnimationFrame(loop);
}
// Call this method when both characters are ready to draw
maybeStartAnimationLoop() {
if (this.state.context && this.state.contextBackground) {
this.startAnimationLoop(this.state.context, this.state.contextBackground);
}
}
stopAnimationLoop() {
if (this.animationFrameIndex) {
cancelAnimationFrame(this.animationFrameIndex);
this.animationFrameIndex = undefined;
console.log("stopAnimationLoop");
}
}
handleHeroPositionChange = (newPosition: SpritePosition) => {
this.setState({ heroPosition: newPosition });
};
handleZombie4PositionChange = (newPosition: SpritePosition) => {
this.setState({ zombie4Position: newPosition });
}
// Additional methods for calculating WPM, updating the progress bar, etc.
render() {
return (
<>
<div style={{ position: "relative", height: this.props.canvasHeight }}>
<div className="parallax-background">
{this.state.layers.map((layer, index) => (
<ParallaxLayer
key={index}
layer={layer}
offset={this.state.backgroundOffsetX}
canvasHeight={this.props.canvasHeight}
/>
))}
</div>
<canvas
style={{ position: "absolute", top: 0, left: 0, zIndex: 2 }}
ref={this.canvasRef}
width={this.props.canvasWidth}
height={this.props.canvasHeight}>
</canvas>
<Hero
ref={this.heroRef}
currentActionType={this.props.heroActionType}
scale={2}
/>
<Zombie4
ref={this.zombie4Ref}
currentActionType={this.props.zombie4ActionType}
scale={2}
/>
</div>
</>
);
}
}
Please advise if the contextBackground
should be handled differently for optimal React best practices.
Codeium at 7:11 am, Jun 12, 2024
The issue with the backgroundOffsetX
jumping back to 0 suggests that there are still conflicting updates to the state, or that the heroDx
being passed to updateCharacterAndBackground
is being reset elsewhere in the code.
Let's focus on the updateCharacterAndBackground
method to ensure that the backgroundOffsetX
update is handled correctly. You still have a redundant this.updateBackgroundPosition
call that you should remove, as it might be causing the conflicting updates. Also, you are passing newX
to updateCharacterAndBackground
, but you should be passing heroDx
instead.
Here is the revised startAnimationLoop
and updateCharacterAndBackground
methods:
startAnimationLoop(context: CanvasRenderingContext2D, contextBackground: CanvasRenderingContext2D) {
const frameDelay = 150; // Delay in milliseconds (100ms for 10 FPS)
let lastFrameTime = performance.now(); // use performance.now() for higher accuracy
const loop = (timestamp: number) => {
const now = performance.now();
const deltaTime = now - lastFrameTime;
if (deltaTime >= frameDelay) { // Control the frame rate
lastFrameTime = now - (deltaTime % frameDelay);
if (this.state.isPhraseComplete) {
this.drawScrollingText(context);
}
// Call updateCharacterAndBackground with the hero's current dx value
const heroDx = this.getHeroDx(); // You need to implement this method to get the current hero dx value
this.updateCharacterAndBackground(context, heroDx);
// Save the request ID to be able to cancel it
this.checkProximityAndSetAction();
};
this.animationFrameIndex = requestAnimationFrame(loop);
};
// Start the animation loop
this.animationFrameIndex = requestAnimationFrame(loop);
}
updateCharacterAndBackground = (_context: CanvasRenderingContext2D, heroDx: number): number => {
// ... existing logic ...
// Remove the call to this.updateBackgroundPosition
// this.updateBackgroundPosition(this.state.backgroundOffsetX + heroDx);
// Update the state with the new hero position, zombie4 position, and background offset
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: newHeroPositionX },
zombie4Position: newZombie4Position,
backgroundOffsetX: newBackgroundOffsetX,
}, () => {
// Log the new background offset after the state has been updated
console.log("New backgroundOffsetX:", this.state.backgroundOffsetX);
});
// ... rest of the method ...
}
I noticed that you have not used contextBackground
in the provided code. It's common to use multiple canvas layers for different game elements, such as foreground, characters, and background. If you intended to draw the background on a separate canvas, you'd need to pass the background context to functions that draw on it, and you should keep the background canvas' drawing operations separate from the main canvas.
If the contextBackground
is not being used, you might want to reconsider whether it's necessary or if you could achieve the desired effect with a single canvas or by other means, such as CSS for static backgrounds.
For optimal React best practices, it's important to minimize direct DOM manipulation and work within the React lifecycle methods and state/props flow as much as possible. If you're working with canvas and animations, however, some imperative coding may be necessary, but it should be encapsulated and managed carefully to avoid conflicts with React's declarative nature.
Lastly, ensure that getHeroDx
(which you need to implement) returns the correct dx
value for the hero, and that it's only updated when the hero actually moves to the right.
User at 7:19 am, Jun 12, 2024
I don't think we need a new getHeroDx()
because draw
returns the dx
of the drawn action, avoiding two calls.
const draw = (
context: CanvasRenderingContext2D,
position: SpritePosition,
scale: number | null
): number => {
const sprite = spritesRef.current[props.currentActionType];
const action = props.actions[props.currentActionType];
const newX = position.leftX + action.dx;
incrementFrameIndex();
// if(props.name.toLocaleLowerCase() === 'zombie4') console.log("zombie4", frameIndexRef.current)
if (sprite) {
sprite.draw(
context,
frameIndexRef.current,
newX,
position.topY,
scale ?? props.scale
);
}
return action.dx;
}
Codeium at 7:20 am, Jun 12, 2024
You're right; if the draw
method from the useBaseCharacter
hook already returns the dx
value, you can use that directly to update the background offset. Let's incorporate this into the updateCharacterAndBackground
method to ensure it uses the returned dx
value from the draw
call correctly.
Here's how you can modify the updateCharacterAndBackground
method to use the returned dx
value:
updateCharacterAndBackground = (_context: CanvasRenderingContext2D, heroDx: number): void => {
const canvasCenterX = this.props.canvasWidth * this.heroXPercent;
const characterReachThreshold = canvasCenterX;
// Clear the canvas for new drawing
_context.clearRect(0, 0, this.props.canvasWidth, this.props.canvasHeight);
let heroResult = 0;
if (this.heroRef.current && _context) {
// The draw method returns the dx, which we can use to update the background position
heroResult = this.heroRef.current.draw(_context, this.state.heroPosition);
}
this.zombie4Ref.current?.draw(_context, this.state.zombie4Position);
// Determine if we need to scroll the background
if (this.isInScrollMode && heroResult !== 0) {
// Calculate the new background offset
const newBackgroundOffsetX = this.state.backgroundOffsetX + heroResult;
// Update the state with the new background offset
this.setState({
backgroundOffsetX: newBackgroundOffsetX,
}, () => {
// Log the new background offset after the state has been updated
console.log("New backgroundOffsetX:", this.state.backgroundOffsetX);
});
// If the hero is currently beyond the threshold, update its position to the threshold
if (this.state.heroPosition.leftX >= characterReachThreshold) {
this.setState({
heroPosition: { ...this.state.heroPosition, leftX: characterReachThreshold },
});
}
// Update the zombie's position based on the hero's dx value
const newZombie4PositionX = this.state.zombie4Position.leftX - heroResult;
this.setState({
zombie4Position: { ...this.state.zombie4Position, leftX: newZombie4PositionX },
});
}
}
In this updated method, we're using the dx
returned by the draw
method of the hero reference (heroResult
). We then use this value to calculate the new background offset and update the positions of both the hero and the zombie.
Please ensure that this.isInScrollMode
is being managed correctly throughout your game logic. It should be set to true
when the game is in a state that requires the background to move (e.g., when the hero is running), and it should be set to false
when the background should not move (e.g., when the hero is idle).
Also, make sure that the draw
method is called within the requestAnimationFrame
loop so that the dx
value is updated correctly with each frame.