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

Codeium Chat Conversation

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:

Performance

  • 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.

Development Complexity

  • 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.

Flexibility

  • 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.

SEO and Accessibility

  • 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.

Consistency Across Browsers

  • 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.

Maintenance

  • 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(&quot;/images/parallax-industrial-pack/parallax-industrial-pack/layers/skill-desc_0002_far-buildings.png&quot;); 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(&quot;/images/parallax-industrial-pack/parallax-industrial-pack/layers/skill-desc_0001_buildings.png&quot;); 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(&quot;/images/parallax-industrial-pack/parallax-industrial-pack/layers/skill-desc_0000_foreground.png&quot;); 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:

  1. Container Size: Ensure that the container .parallax-background has a defined height and width and is not collapsed. If it's collapsed, the background divs would not be visible.

  2. 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.

  3. Z-Index and Visibility: Make sure that the .parallax-layer elements are not being obscured by other elements with a higher z-index. Also, check if any parent elements have display: none or visibility: hidden set.

  4. 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%;
}
  1. Background Position: The backgroundPositionX might be set to 0px initially, which would be correct if the background offset state starts at 0. However, make sure that the offset prop passed to ParallaxLayer changes when the Hero character moves, so that the background position gets updated.

  2. Browser Rendering: In some cases, the browser may not render the image if backgroundPositionX is not supported. Try using the shorthand background-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:

Issue 1: @xterm/xterm Terminal Overlaying the Canvas

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.

Issue 2: Not Seeing Background Images

For the background images not appearing, you'll need to ensure a few things:

  1. 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 set z-index to ensure it's below the canvas and other content.

  2. The ParallaxLayer component should render the background images correctly. Double-check the background-size, background-position, and background-repeat CSS properties. You might want to set background-size to cover or a specific pixel value instead of a percentage if the images are not appearing as expected.

  3. 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.

  4. The top and left CSS properties in the inline styles of the .parallax-layer elements are set to 0px. If these properties are necessary, they should also be applied in your ParallaxLayer 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:

  1. Set the background-size property to auto {percentage}% to preserve the aspect ratio and scale the height.
  2. Use the height property of .parallax-layer class to set the layers' heights to a percentage of canvasHeight.

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.

⚠️ **GitHub.com Fallback** ⚠️