Merge pull request 'Feature/Proof of Concept' (#1) from Feature/Proof-of-Concept into main
Reviewed-on: #1
This commit is contained in:
commit
2050d27f27
57 changed files with 6605 additions and 0 deletions
24
.forgejo/release-template.md
Normal file
24
.forgejo/release-template.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
Release {{VERSION}}
|
||||
|
||||
Docker Image: {{IMAGE_NAME}}
|
||||
Commit: {{COMMIT_SHORT}} ({{COMMIT_HASH}})
|
||||
Build Date: {{BUILD_DATE}}
|
||||
Author: {{COMMIT_AUTHOR}}
|
||||
|
||||
## Changes
|
||||
|
||||
{{COMMITS}}
|
||||
|
||||
## Docker Image
|
||||
|
||||
```
|
||||
docker pull {{IMAGE_NAME}}
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
The image is also available as `latest`:
|
||||
```
|
||||
docker pull git.jusemon.com/jusemon/threejs-test:latest
|
||||
```
|
||||
|
||||
53
.forgejo/workflows/build-and-validate.yaml
Normal file
53
.forgejo/workflows/build-and-validate.yaml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-validate:
|
||||
name: Build and Validate
|
||||
runs-on: ubuntu
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate PR Title
|
||||
run: |
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
echo "PR Title: $PR_TITLE"
|
||||
|
||||
# Check if PR title starts with valid prefix
|
||||
if [[ "$PR_TITLE" =~ ^(Release|Feature|Hotfix|Bugfix)/ ]]; then
|
||||
echo "✅ PR title is valid: $PR_TITLE"
|
||||
else
|
||||
echo "❌ PR title must start with one of: Release/, Feature/, Hotfix/, or Bugfix/"
|
||||
echo "Current title: $PR_TITLE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker build --build-arg VERSION=test --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -t threejs-test:test .
|
||||
|
||||
- name: Validate Image
|
||||
run: |
|
||||
docker run --rm -d --name test-container -p 8080:80 threejs-test:test
|
||||
sleep 2
|
||||
curl -f http://localhost:8080 || exit 1
|
||||
docker stop test-container
|
||||
|
||||
- name: Job Summary
|
||||
if: success()
|
||||
run: |
|
||||
SUMMARY_FILE="${FORGEJO_STEP_SUMMARY}"
|
||||
cat >> "$SUMMARY_FILE" << 'EOF'
|
||||
## ✅ Build and Validation Complete
|
||||
|
||||
- ✅ Docker image built successfully
|
||||
- ✅ Image validated (container started and HTTP check passed)
|
||||
|
||||
The image is ready for deployment.
|
||||
EOF
|
||||
cat "$SUMMARY_FILE"
|
||||
336
.forgejo/workflows/publish-and-deploy.yaml
Normal file
336
.forgejo/workflows/publish-and-deploy.yaml
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish to Registry
|
||||
runs-on: ubuntu
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for tags
|
||||
token: ${{ secrets.FORGEBOT_ACCESS_TOKEN }}
|
||||
|
||||
- name: Login to Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.jusemon.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Determine Version
|
||||
id: version
|
||||
run: |
|
||||
# Get latest version tag
|
||||
LATEST_TAG=$(git describe --tags --match 'v*.*.*' --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
LATEST_VERSION="${LATEST_TAG#v}"
|
||||
|
||||
# Parse version components
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$LATEST_VERSION"
|
||||
MAJOR=${MAJOR:-0}
|
||||
MINOR=${MINOR:-0}
|
||||
PATCH=${PATCH:-0}
|
||||
|
||||
# Get PR title from event or merge commit message
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
echo "PR Title: $PR_TITLE"
|
||||
else
|
||||
# For push events, check merge commit message
|
||||
# Merge commits often have format: "Merge pull request #X from branch" or "PR Title (#X)"
|
||||
COMMIT_MSG=$(git log -1 --pretty=format:"%s" HEAD)
|
||||
echo "Commit message: $COMMIT_MSG"
|
||||
|
||||
# Try to extract PR title from merge commit
|
||||
# Look for patterns like "Release/...", "Feature/...", etc. in the commit message
|
||||
if echo "$COMMIT_MSG" | grep -qE "^(Release|Feature|Hotfix|Bugfix)/"; then
|
||||
# If commit message itself starts with prefix, use it
|
||||
PR_TITLE=$(echo "$COMMIT_MSG" | grep -oE "^(Release|Feature|Hotfix|Bugfix)/[^[:space:]]*" | head -1)
|
||||
elif echo "$COMMIT_MSG" | grep -qE "(Release|Feature|Hotfix|Bugfix)/"; then
|
||||
# Extract prefix pattern from anywhere in the message
|
||||
PR_TITLE=$(echo "$COMMIT_MSG" | grep -oE "(Release|Feature|Hotfix|Bugfix)/[^[:space:]]*" | head -1)
|
||||
else
|
||||
# Check if it's a merge commit and try to get the original PR title
|
||||
# For Forgejo, merge commits might reference the PR
|
||||
PR_TITLE="$COMMIT_MSG"
|
||||
echo "⚠️ Could not extract PR title prefix from commit message, using full message"
|
||||
fi
|
||||
echo "Extracted PR title: $PR_TITLE"
|
||||
fi
|
||||
|
||||
# Determine version type from PR title prefix
|
||||
if [[ "$PR_TITLE" =~ ^Release/ ]]; then
|
||||
VERSION_TYPE="release"
|
||||
echo "Detected: RELEASE - incrementing major version"
|
||||
elif [[ "$PR_TITLE" =~ ^Feature/ ]]; then
|
||||
VERSION_TYPE="feature"
|
||||
echo "Detected: FEATURE - incrementing minor version"
|
||||
elif [[ "$PR_TITLE" =~ ^(Hotfix|Bugfix)/ ]]; then
|
||||
VERSION_TYPE="patch"
|
||||
echo "Detected: HOTFIX/BUGFIX - incrementing patch version"
|
||||
else
|
||||
echo "⚠️ Warning: PR title does not match expected pattern (Release/, Feature/, Hotfix/, or Bugfix/)"
|
||||
echo "Defaulting to FEATURE (minor version increment)"
|
||||
VERSION_TYPE="feature"
|
||||
fi
|
||||
|
||||
# Increment version based on type
|
||||
if [[ "$LATEST_TAG" == "v0.0.0" ]]; then
|
||||
# First version
|
||||
NEW_VERSION="0.1.0"
|
||||
elif [[ "$VERSION_TYPE" == "release" ]]; then
|
||||
# Release: increment major version and reset minor/patch (0.1.5 -> 1.0.0)
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
elif [[ "$VERSION_TYPE" == "feature" ]]; then
|
||||
# Feature: increment minor version and reset patch (0.1.5 -> 0.2.0)
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
else
|
||||
# Hotfix/Bugfix: increment patch version (0.1.5 -> 0.1.6)
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
fi
|
||||
|
||||
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_type=$VERSION_TYPE" >> $GITHUB_OUTPUT
|
||||
echo "Latest tag: $LATEST_TAG"
|
||||
echo "Version type: $VERSION_TYPE"
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "Current VERSION file: $(cat VERSION 2>/dev/null || echo 'not found')"
|
||||
|
||||
- name: Update Version Files
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
|
||||
echo "📝 Updating version files to: $VERSION"
|
||||
|
||||
# Configure git
|
||||
git config user.name "forgebot"
|
||||
git config user.email "forgebot@forgejo.io"
|
||||
|
||||
# Fetch latest changes to avoid conflicts
|
||||
git fetch origin main || echo "Fetch completed or already up to date"
|
||||
git checkout main || echo "Already on main"
|
||||
|
||||
# Update VERSION file
|
||||
echo "$VERSION" > VERSION
|
||||
|
||||
# Update portainer.yml with new version
|
||||
sed -i "s|\(image: git.jusemon.com/jusemon/threejs-test:\)[0-9.]*|\1$VERSION|" portainer.yml
|
||||
|
||||
# Verify the updates
|
||||
if grep -q "^$VERSION$" VERSION && grep -q "image: git.jusemon.com/jusemon/threejs-test:$VERSION" portainer.yml; then
|
||||
echo "✅ Successfully updated VERSION and portainer.yml to $VERSION"
|
||||
else
|
||||
echo "❌ Failed to update version files"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if git diff --quiet VERSION portainer.yml; then
|
||||
echo "⚠️ No changes to commit (files already up to date)"
|
||||
echo "VERSION_COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
else
|
||||
# Stage and commit with [skip ci] to prevent infinite loop
|
||||
# Note: Forgejo Actions should respect [skip ci] in commit messages
|
||||
git add VERSION portainer.yml
|
||||
git commit -m "chore: update version to $VERSION [skip ci]" || {
|
||||
echo "⚠️ Commit failed (may already be committed)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Push to main branch (remote should already be configured with token)
|
||||
git push origin main || {
|
||||
echo "⚠️ Push failed (check token permissions)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Get the commit hash of the version update commit
|
||||
VERSION_COMMIT_HASH=$(git rev-parse HEAD)
|
||||
echo "VERSION_COMMIT_HASH=$VERSION_COMMIT_HASH" >> $GITHUB_ENV
|
||||
echo "✅ Successfully committed and pushed version update to $VERSION (commit: $VERSION_COMMIT_HASH)"
|
||||
fi
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
IMAGE_NAME="git.jusemon.com/jusemon/threejs-test:$VERSION"
|
||||
docker build --build-arg VERSION="$VERSION" --build-arg BUILD_DATE="$BUILD_DATE" -t "$IMAGE_NAME" .
|
||||
echo "IMAGE_NAME=$IMAGE_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Push Docker Image
|
||||
run: |
|
||||
IMAGE_NAME="git.jusemon.com/jusemon/threejs-test:${{ steps.version.outputs.version }}"
|
||||
docker push "$IMAGE_NAME"
|
||||
|
||||
# Also tag as 'latest' for main branch
|
||||
docker tag "$IMAGE_NAME" "git.jusemon.com/jusemon/threejs-test:latest"
|
||||
docker push "git.jusemon.com/jusemon/threejs-test:latest"
|
||||
|
||||
- name: Generate Release Notes
|
||||
id: release_notes
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
IMAGE_NAME="git.jusemon.com/jusemon/threejs-test:$VERSION"
|
||||
# Use the version update commit hash (which has the correct version in files)
|
||||
COMMIT_HASH="${VERSION_COMMIT_HASH:-$(git rev-parse HEAD)}"
|
||||
COMMIT_SHORT=$(git rev-parse --short "$COMMIT_HASH")
|
||||
BUILD_DATE=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
# Get commits since last tag, excluding version update commits
|
||||
LATEST_TAG=$(git describe --tags --match 'v*.*.*' --abbrev=0 2>/dev/null || echo "")
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
# Get commits excluding "chore: update version" commits (from previous releases)
|
||||
# We want commits from the tag to the version update commit (exclusive of the version commit itself)
|
||||
COMMITS=$(git log ${LATEST_TAG}..${COMMIT_HASH}^ --pretty=format:"- %s (%h)" --no-merges | grep -v "chore: update version")
|
||||
else
|
||||
# Get commits excluding version update commits
|
||||
COMMITS=$(git log ${COMMIT_HASH}^ --pretty=format:"- %s (%h)" --no-merges -10 | grep -v "chore: update version")
|
||||
fi
|
||||
|
||||
# Get author of the version update commit (or HEAD if no version commit)
|
||||
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an <%ae>" "$COMMIT_HASH")
|
||||
|
||||
# Read template and replace placeholders
|
||||
TEMPLATE_FILE=".forgejo/release-template.md"
|
||||
if [[ ! -f "$TEMPLATE_FILE" ]]; then
|
||||
echo "Error: Template file not found: $TEMPLATE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Replace placeholders in template
|
||||
# Handle COMMITS separately due to multi-line content that can break sed
|
||||
# First, replace all single-line placeholders
|
||||
sed -e "s|{{VERSION}}|$VERSION|g" \
|
||||
-e "s|{{IMAGE_NAME}}|$IMAGE_NAME|g" \
|
||||
-e "s|{{COMMIT_HASH}}|$COMMIT_HASH|g" \
|
||||
-e "s|{{COMMIT_SHORT}}|$COMMIT_SHORT|g" \
|
||||
-e "s|{{BUILD_DATE}}|$BUILD_DATE|g" \
|
||||
-e "s|{{COMMIT_AUTHOR}}|$COMMIT_AUTHOR|g" \
|
||||
"$TEMPLATE_FILE" > /tmp/release_message_temp.txt
|
||||
|
||||
# Replace COMMITS placeholder - use a while loop to handle multi-line safely
|
||||
if [[ -n "$COMMITS" ]]; then
|
||||
# Write COMMITS to a temp file and use it for replacement
|
||||
echo "$COMMITS" > /tmp/commits.txt
|
||||
# Use a simple approach: read template line by line and replace
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" == *"{{COMMITS}}"* ]]; then
|
||||
cat /tmp/commits.txt
|
||||
else
|
||||
echo "$line"
|
||||
fi
|
||||
done < /tmp/release_message_temp.txt > /tmp/release_message.txt
|
||||
else
|
||||
# If no commits, just remove the placeholder
|
||||
sed 's|{{COMMITS}}||g' /tmp/release_message_temp.txt > /tmp/release_message.txt
|
||||
fi
|
||||
|
||||
echo "Release notes generated from template"
|
||||
|
||||
- name: Create Git Tag
|
||||
run: |
|
||||
git config user.name "forgejo-actions"
|
||||
git config user.email "forgejo-actions@forgejo.io"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
# Use the version update commit hash (which has the correct version in files)
|
||||
VERSION_COMMIT="${VERSION_COMMIT_HASH:-$(git rev-parse HEAD)}"
|
||||
|
||||
# Check if tag already exists
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG already exists, skipping tag creation"
|
||||
echo "TAG_CREATED=false" >> $GITHUB_ENV
|
||||
else
|
||||
# Create tag pointing to the version update commit (which has correct VERSION file)
|
||||
git tag -a "$TAG" -F /tmp/release_message.txt "$VERSION_COMMIT"
|
||||
git push origin "$TAG"
|
||||
echo "Created tag $TAG pointing to version update commit $VERSION_COMMIT with detailed release notes"
|
||||
echo "TAG_CREATED=true" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Job Summary
|
||||
if: success()
|
||||
run: |
|
||||
SUMMARY_FILE="${FORGEJO_STEP_SUMMARY:-/dev/stdout}"
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
IMAGE_NAME="git.jusemon.com/jusemon/threejs-test:$VERSION"
|
||||
TAG_STATUS="${TAG_CREATED:-false}"
|
||||
|
||||
cat >> "$SUMMARY_FILE" << EOF
|
||||
## 🚀 Release Published
|
||||
|
||||
**Version:** \`$VERSION\`
|
||||
**Docker Image:** \`$IMAGE_NAME\`
|
||||
**Git Tag:** \`$TAG\`
|
||||
|
||||
### Published Images
|
||||
- ✅ \`$IMAGE_NAME\`
|
||||
- ✅ \`git.jusemon.com/jusemon/threejs-test:latest\`
|
||||
|
||||
### Git Tag
|
||||
EOF
|
||||
|
||||
if [[ "$TAG_STATUS" == "true" ]]; then
|
||||
echo "- ✅ Created and pushed \`$TAG\` with release notes" >> "$SUMMARY_FILE"
|
||||
else
|
||||
echo "- ⚠️ Tag \`$TAG\` already exists, skipped creation" >> "$SUMMARY_FILE"
|
||||
fi
|
||||
|
||||
cat >> "$SUMMARY_FILE" << EOF
|
||||
|
||||
### Version Files
|
||||
- ✅ VERSION file updated to \`$VERSION\`
|
||||
- ✅ portainer.yml updated to \`$VERSION\`
|
||||
|
||||
### Pull Command
|
||||
\`\`\`bash
|
||||
docker pull git.jusemon.com/jusemon/threejs-test:latest
|
||||
\`\`\`
|
||||
EOF
|
||||
cat "$SUMMARY_FILE"
|
||||
|
||||
deploy:
|
||||
name: Deploy to Portainer
|
||||
runs-on: ubuntu
|
||||
needs: publish
|
||||
steps:
|
||||
- name: Trigger Portainer Deployment
|
||||
run: |
|
||||
WEBHOOK_URL="${{ secrets.PORTAINER_WEBHOOK_URL }}"
|
||||
|
||||
if [[ -z "$WEBHOOK_URL" ]]; then
|
||||
echo "⚠️ Warning: PORTAINER_WEBHOOK_URL secret not set, skipping webhook call"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🚀 Triggering Portainer deployment for latest version"
|
||||
|
||||
# Call Portainer webhook to trigger stack update
|
||||
HTTP_CODE=$(curl -s -o /tmp/webhook_response.txt -w "%{http_code}" -X POST "$WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
--max-time 30)
|
||||
|
||||
if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then
|
||||
echo "✅ Successfully triggered Portainer deployment (HTTP $HTTP_CODE)"
|
||||
if [[ -f /tmp/webhook_response.txt ]]; then
|
||||
echo "Response: $(cat /tmp/webhook_response.txt)"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Warning: Webhook call returned HTTP $HTTP_CODE"
|
||||
if [[ -f /tmp/webhook_response.txt ]]; then
|
||||
echo "Response: $(cat /tmp/webhook_response.txt)"
|
||||
fi
|
||||
# Don't fail the workflow if webhook fails
|
||||
exit 0
|
||||
fi
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
.vite/
|
||||
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx config if needed (optional)
|
||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
132
README.md
Normal file
132
README.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Slime Genesis - ECS RPG PoC
|
||||
|
||||
A complete proof of concept for **Slime Genesis: The Awakening of the Entity** built with vanilla JavaScript using an Entity Component System (ECS) architecture.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Run the development server:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Open your browser** to the URL shown (usually `http://localhost:5173`)
|
||||
|
||||
## Controls
|
||||
|
||||
- **WASD** or **Arrow Keys**: Move your slime
|
||||
- **Mouse**: Aim/face direction
|
||||
- **Left Click** or **Space**: Attack
|
||||
- **Shift**: Toggle stealth mode
|
||||
- **1-9**: Activate skills (when you have them)
|
||||
|
||||
## Features
|
||||
|
||||
### Core Systems
|
||||
- ✅ **ECS Architecture**: Entity Component System for flexible game design
|
||||
- ✅ **Real-time Combat**: Fast-paced action combat with form-specific styles
|
||||
- ✅ **Evolution System**: Three paths (Human, Beast, Slime) that change based on absorption
|
||||
- ✅ **Skill Absorption**: Defeat creatures to gain their abilities
|
||||
- ✅ **Stealth System**: Form-specific stealth mechanics (Social, Hunting, Structural)
|
||||
- ✅ **RPG Systems**: Stats, leveling, XP, inventory, equipment
|
||||
- ✅ **AI System**: Intelligent creature behaviors (wander, chase, combat, flee)
|
||||
- ✅ **Projectile System**: Skills can create projectiles (Water Gun, etc.)
|
||||
|
||||
### Graphics & Polish
|
||||
- ✅ **Animated Slime**: Smooth morphing blob with jiggle physics
|
||||
- ✅ **Combat Effects**: Damage numbers, attack indicators, particle effects
|
||||
- ✅ **Absorption Visuals**: Swirling particles and color transitions
|
||||
- ✅ **Stealth Indicators**: Visibility meters and detection warnings
|
||||
- ✅ **Polished UI**: Health bars, XP bars, skill hotbar, stat displays
|
||||
|
||||
### Skills
|
||||
- **Water Gun**: Shoot a jet of water at enemies
|
||||
- **Fire Breath**: Breathe fire in a cone
|
||||
- **Pounce**: Leap forward and damage enemies
|
||||
- **Stealth Mode**: Enter stealth mode (form-specific)
|
||||
|
||||
## Architecture
|
||||
|
||||
This game uses a pure ECS (Entity Component System) architecture:
|
||||
|
||||
- **Entities**: Game objects (player, creatures, projectiles)
|
||||
- **Components**: Data containers (Position, Health, Stats, Evolution, etc.)
|
||||
- **Systems**: Logic processors (RenderSystem, CombatSystem, AbsorptionSystem, etc.)
|
||||
|
||||
This architecture makes it easy to:
|
||||
- Add new skills and abilities
|
||||
- Create mutations and combinations
|
||||
- Extend creature behaviors
|
||||
- Add new game mechanics
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── core/ # ECS framework
|
||||
│ ├── Engine.js # Main game loop
|
||||
│ ├── Entity.js # Entity manager
|
||||
│ ├── Component.js # Base component
|
||||
│ └── System.js # Base system
|
||||
├── components/ # All game components
|
||||
│ ├── Position.js
|
||||
│ ├── Health.js
|
||||
│ ├── Stats.js
|
||||
│ ├── Evolution.js
|
||||
│ └── ...
|
||||
├── systems/ # All game systems
|
||||
│ ├── RenderSystem.js
|
||||
│ ├── CombatSystem.js
|
||||
│ ├── AbsorptionSystem.js
|
||||
│ └── ...
|
||||
├── skills/ # Skill system
|
||||
│ ├── Skill.js
|
||||
│ ├── SkillRegistry.js
|
||||
│ └── skills/ # Individual skills
|
||||
├── items/ # Item system
|
||||
│ ├── Item.js
|
||||
│ └── ItemRegistry.js
|
||||
├── world/ # World management
|
||||
│ └── World.js
|
||||
└── main.js # Entry point
|
||||
```
|
||||
|
||||
## Gameplay Loop
|
||||
|
||||
1. **Explore**: Move around the cave, discover creatures
|
||||
2. **Fight**: Engage in real-time combat with creatures
|
||||
3. **Absorb**: Defeat creatures to gain evolution points and skills
|
||||
4. **Evolve**: Your form changes based on what you absorb
|
||||
5. **Level Up**: Gain XP, increase stats, unlock new possibilities
|
||||
6. **Stealth**: Use form-specific stealth to avoid or ambush enemies
|
||||
|
||||
## Evolution Paths
|
||||
|
||||
- **Human Path**: Absorb humanoids to gain human traits, access to social areas
|
||||
- **Beast Path**: Absorb beasts to become a predator, gain physical power
|
||||
- **Slime Path**: Maintain your original form, gain unique abilities
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **No External Dependencies**: Pure vanilla JavaScript (except Vite for dev server)
|
||||
- **Canvas 2D**: High-performance rendering with Canvas API
|
||||
- **Modular Design**: Easy to extend and modify
|
||||
- **ECS Pattern**: Scalable architecture for complex game mechanics
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- More skills and mutations
|
||||
- Multiple areas/zones
|
||||
- NPCs with dialogue
|
||||
- Quest system
|
||||
- Equipment system expansion
|
||||
- Save/load functionality
|
||||
- More creature types and behaviors
|
||||
|
||||
## License
|
||||
|
||||
This is a proof of concept. Use as you see fit for your game development.
|
||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1.0
|
||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
services:
|
||||
game:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- .:/app
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
26
eslint.config.js
Normal file
26
eslint.config.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
performance: "readonly",
|
||||
requestAnimationFrame: "readonly",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", {
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}],
|
||||
"no-console": "off",
|
||||
"indent": ["error", 2],
|
||||
},
|
||||
},
|
||||
];
|
||||
31
index.html
Normal file
31
index.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Slime Genesis - PoC</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #1a1a1a;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
#game-container {
|
||||
border: 2px solid #4a4a4a;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 150, 0.3);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container">
|
||||
<canvas id="game-canvas" tabindex="0"></canvas>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2281
package-lock.json
generated
Normal file
2281
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
Normal file
20
package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "slime-genesis-poc",
|
||||
"version": "0.1.0",
|
||||
"description": "Proof of Concept for Slime Genesis: The Awakening of the Entity",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint": "^9.39.2",
|
||||
"globals": "^17.0.0",
|
||||
"terser": "^5.44.1",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
38
src/GameConfig.js
Normal file
38
src/GameConfig.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Centralized Game Configuration
|
||||
* Thresholds, rates, and balancing constants
|
||||
*/
|
||||
export const GameConfig = {
|
||||
Evolution: {
|
||||
totalTarget: 150,
|
||||
thresholds: {
|
||||
hardenedShell: { constitution: 25 },
|
||||
electricSkin: { intelligence: 25 },
|
||||
glowingBody: { human: 50 }
|
||||
}
|
||||
},
|
||||
|
||||
Absorption: {
|
||||
range: 80,
|
||||
healPercentMin: 0.1,
|
||||
healPercentMax: 0.2,
|
||||
skillAbsorptionChance: 0.3,
|
||||
mutationChance: 0.1,
|
||||
removalDelay: 3.0, // Seconds after death
|
||||
},
|
||||
|
||||
Combat: {
|
||||
knockbackPower: 150,
|
||||
defaultAttackArc: 0.5,
|
||||
damageReflectionPercent: 0.2,
|
||||
hardenedShellReduction: 0.7
|
||||
},
|
||||
|
||||
AI: {
|
||||
detectionAwarenessThreshold: 0.7,
|
||||
passiveAwarenessThreshold: 0.95,
|
||||
fleeAwarenessThreshold: 0.5,
|
||||
awarenessLossRate: 0.5,
|
||||
awarenessGainMultiplier: 2.0
|
||||
}
|
||||
};
|
||||
52
src/components/AI.js
Normal file
52
src/components/AI.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class AI extends Component {
|
||||
constructor(behaviorType = 'wander') {
|
||||
super('AI');
|
||||
this.behaviorType = behaviorType; // 'wander', 'patrol', 'chase', 'flee', 'combat'
|
||||
this.state = 'idle'; // 'idle', 'moving', 'attacking', 'fleeing'
|
||||
this.target = null; // Entity ID to target
|
||||
this.awareness = 0; // 0-1, how aware of player
|
||||
this.alertRadius = 150;
|
||||
this.chaseRadius = 300;
|
||||
this.fleeRadius = 100;
|
||||
|
||||
// Behavior parameters
|
||||
this.wanderSpeed = 50;
|
||||
this.wanderDirection = Math.random() * Math.PI * 2;
|
||||
this.wanderChangeTime = 0;
|
||||
this.wanderChangeInterval = 2.0; // seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Set behavior type
|
||||
*/
|
||||
setBehavior(type) {
|
||||
this.behaviorType = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set target entity
|
||||
*/
|
||||
setTarget(entityId) {
|
||||
this.target = entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear target
|
||||
*/
|
||||
clearTarget() {
|
||||
this.target = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update awareness
|
||||
*/
|
||||
updateAwareness(delta, maxAwareness = 1.0) {
|
||||
this.awareness = Math.min(maxAwareness, this.awareness + delta);
|
||||
if (this.awareness <= 0) {
|
||||
this.awareness = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
src/components/Absorbable.js
Normal file
54
src/components/Absorbable.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class Absorbable extends Component {
|
||||
constructor() {
|
||||
super('Absorbable');
|
||||
this.evolutionData = {
|
||||
human: 0,
|
||||
beast: 0,
|
||||
slime: 0
|
||||
};
|
||||
this.skillsGranted = []; // Array of skill IDs that can be absorbed
|
||||
this.skillAbsorptionChance = 0.3; // 30% chance to absorb a skill
|
||||
this.mutationChance = 0.1; // 10% chance for mutation
|
||||
this.absorbed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set evolution data
|
||||
*/
|
||||
setEvolutionData(human, beast, slime) {
|
||||
this.evolutionData = { human, beast, slime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a skill that can be absorbed
|
||||
*/
|
||||
addSkill(skillId, chance = null) {
|
||||
this.skillsGranted.push({
|
||||
id: skillId,
|
||||
chance: chance || this.skillAbsorptionChance
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skills that were successfully absorbed (rolls for each)
|
||||
*/
|
||||
getAbsorbedSkills() {
|
||||
const absorbed = [];
|
||||
for (const skill of this.skillsGranted) {
|
||||
if (Math.random() < skill.chance) {
|
||||
absorbed.push(skill.id);
|
||||
}
|
||||
}
|
||||
return absorbed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if should mutate
|
||||
*/
|
||||
shouldMutate() {
|
||||
return Math.random() < this.mutationChance;
|
||||
}
|
||||
}
|
||||
|
||||
52
src/components/Combat.js
Normal file
52
src/components/Combat.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class Combat extends Component {
|
||||
constructor() {
|
||||
super('Combat');
|
||||
this.attackDamage = 10;
|
||||
this.defense = 5;
|
||||
this.attackSpeed = 1.0; // Attacks per second
|
||||
this.attackRange = 50;
|
||||
this.lastAttackTime = 0;
|
||||
this.attackCooldown = 0;
|
||||
|
||||
// Combat state
|
||||
this.isAttacking = false;
|
||||
this.attackDirection = 0; // Angle in radians
|
||||
this.knockbackResistance = 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if can attack
|
||||
*/
|
||||
canAttack(currentTime) {
|
||||
return (currentTime - this.lastAttackTime) >= (1.0 / this.attackSpeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform attack
|
||||
*/
|
||||
attack(currentTime, direction) {
|
||||
if (!this.canAttack(currentTime)) return false;
|
||||
|
||||
this.lastAttackTime = currentTime;
|
||||
this.isAttacking = true;
|
||||
this.attackDirection = direction;
|
||||
this.attackCooldown = 0.3; // Attack animation duration
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update attack state
|
||||
*/
|
||||
update(deltaTime) {
|
||||
if (this.attackCooldown > 0) {
|
||||
this.attackCooldown -= deltaTime;
|
||||
if (this.attackCooldown <= 0) {
|
||||
this.isAttacking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
105
src/components/Evolution.js
Normal file
105
src/components/Evolution.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
import { GameConfig } from '../GameConfig.js';
|
||||
import { Events } from '../core/EventBus.js';
|
||||
|
||||
export class Evolution extends Component {
|
||||
constructor() {
|
||||
super('Evolution');
|
||||
this.human = 0;
|
||||
this.beast = 0;
|
||||
this.slime = 100; // Start as pure slime
|
||||
|
||||
// Mutation tracking
|
||||
this.mutations = new Set(); // Use Set for unique functional mutations
|
||||
this.mutationEffects = {
|
||||
electricSkin: false,
|
||||
glowingBody: false,
|
||||
hardenedShell: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and apply functional mutations
|
||||
*/
|
||||
checkMutations(stats, engine = null) {
|
||||
if (!stats) return;
|
||||
|
||||
const config = GameConfig.Evolution.thresholds;
|
||||
|
||||
// Threshold-based functional mutations
|
||||
if (stats.constitution > config.hardenedShell.constitution && !this.mutationEffects.hardenedShell) {
|
||||
this.mutationEffects.hardenedShell = true;
|
||||
this.mutations.add('Hardened Shell');
|
||||
if (engine) engine.emit(Events.MUTATION_GAINED, { name: 'Hardened Shell', description: 'Defense Up' });
|
||||
console.log("Mutation Gained: Hardened Shell (Defense Up)");
|
||||
}
|
||||
|
||||
if (stats.intelligence > config.electricSkin.intelligence && !this.mutationEffects.electricSkin) {
|
||||
this.mutationEffects.electricSkin = true;
|
||||
this.mutations.add('Electric Skin');
|
||||
if (engine) engine.emit(Events.MUTATION_GAINED, { name: 'Electric Skin', description: 'Damage Reflection' });
|
||||
console.log("Mutation Gained: Electric Skin (Damage Reflection)");
|
||||
}
|
||||
|
||||
if (this.human > config.glowingBody.human && !this.mutationEffects.glowingBody) {
|
||||
this.mutationEffects.glowingBody = true;
|
||||
this.mutations.add('Bioluminescence');
|
||||
if (engine) engine.emit(Events.MUTATION_GAINED, { name: 'Bioluminescence', description: 'Light Source' });
|
||||
console.log("Mutation Gained: Bioluminescence (Light Source)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add evolution points
|
||||
*/
|
||||
addEvolution(human = 0, beast = 0, slime = 0) {
|
||||
this.human += human;
|
||||
this.beast += beast;
|
||||
this.slime += slime;
|
||||
|
||||
// Normalize to keep total around target
|
||||
const target = GameConfig.Evolution.totalTarget;
|
||||
const total = this.human + this.beast + this.slime;
|
||||
if (total > target) {
|
||||
const factor = target / total;
|
||||
this.human *= factor;
|
||||
this.beast *= factor;
|
||||
this.slime *= factor;
|
||||
}
|
||||
|
||||
// Ensure no negative values
|
||||
this.human = Math.max(0, this.human);
|
||||
this.beast = Math.max(0, this.beast);
|
||||
this.slime = Math.max(0, this.slime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dominant form
|
||||
*/
|
||||
getDominantForm() {
|
||||
if (this.human > this.beast && this.human > this.slime) {
|
||||
return 'human';
|
||||
} else if (this.beast > this.human && this.beast > this.slime) {
|
||||
return 'beast';
|
||||
} else {
|
||||
return 'slime';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form percentage (0-1)
|
||||
*/
|
||||
getFormPercentage(form) {
|
||||
const total = this.human + this.beast + this.slime;
|
||||
if (total === 0) return 0;
|
||||
return this[form] / total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a mutation
|
||||
*/
|
||||
addMutation(mutation) {
|
||||
this.mutations.push(mutation);
|
||||
}
|
||||
}
|
||||
|
||||
29
src/components/Health.js
Normal file
29
src/components/Health.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class Health extends Component {
|
||||
constructor(maxHp = 100, hp = null) {
|
||||
super('Health');
|
||||
this.maxHp = maxHp;
|
||||
this.hp = hp !== null ? hp : maxHp;
|
||||
this.regeneration = 2; // HP per second (slime regenerates)
|
||||
this.lastDamageTime = 0;
|
||||
this.invulnerable = false;
|
||||
this.invulnerabilityDuration = 0;
|
||||
}
|
||||
|
||||
takeDamage(amount) {
|
||||
if (this.invulnerable) return 0;
|
||||
this.hp = Math.max(0, this.hp - amount);
|
||||
this.lastDamageTime = Date.now();
|
||||
return amount;
|
||||
}
|
||||
|
||||
heal(amount) {
|
||||
this.hp = Math.min(this.maxHp, this.hp + amount);
|
||||
}
|
||||
|
||||
isDead() {
|
||||
return this.hp <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
71
src/components/Inventory.js
Normal file
71
src/components/Inventory.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class Inventory extends Component {
|
||||
constructor() {
|
||||
super('Inventory');
|
||||
this.items = []; // Array of item objects
|
||||
this.maxSize = 20;
|
||||
this.equipped = {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
accessory: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to inventory
|
||||
*/
|
||||
addItem(item) {
|
||||
if (this.items.length < this.maxSize) {
|
||||
this.items.push(item);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item
|
||||
*/
|
||||
removeItem(itemId) {
|
||||
const index = this.items.findIndex(item => item.id === itemId);
|
||||
if (index > -1) {
|
||||
return this.items.splice(index, 1)[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equip an item
|
||||
*/
|
||||
equipItem(itemId, slot) {
|
||||
const item = this.items.find(i => i.id === itemId);
|
||||
if (!item) return false;
|
||||
|
||||
// Unequip current item in slot
|
||||
if (this.equipped[slot]) {
|
||||
this.items.push(this.equipped[slot]);
|
||||
}
|
||||
|
||||
// Equip new item
|
||||
this.equipped[slot] = item;
|
||||
const index = this.items.indexOf(item);
|
||||
if (index > -1) {
|
||||
this.items.splice(index, 1);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unequip an item
|
||||
*/
|
||||
unequipItem(slot) {
|
||||
if (this.equipped[slot]) {
|
||||
this.items.push(this.equipped[slot]);
|
||||
this.equipped[slot] = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
11
src/components/Position.js
Normal file
11
src/components/Position.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class Position extends Component {
|
||||
constructor(x = 0, y = 0, rotation = 0) {
|
||||
super('Position');
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.rotation = rotation;
|
||||
}
|
||||
}
|
||||
|
||||
45
src/components/SkillProgress.js
Normal file
45
src/components/SkillProgress.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
/**
|
||||
* Tracks progress toward learning skills
|
||||
* Need to absorb multiple enemies with the same skill to learn it
|
||||
*/
|
||||
export class SkillProgress extends Component {
|
||||
constructor() {
|
||||
super('SkillProgress');
|
||||
this.skillProgress = new Map(); // skillId -> count (how many times absorbed)
|
||||
this.requiredAbsorptions = 5; // Need to absorb 5 enemies with a skill to learn it
|
||||
}
|
||||
|
||||
/**
|
||||
* Add progress toward learning a skill
|
||||
*/
|
||||
addSkillProgress(skillId) {
|
||||
const current = this.skillProgress.get(skillId) || 0;
|
||||
this.skillProgress.set(skillId, current + 1);
|
||||
return this.skillProgress.get(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if skill can be learned
|
||||
*/
|
||||
canLearnSkill(skillId) {
|
||||
const progress = this.skillProgress.get(skillId) || 0;
|
||||
return progress >= this.requiredAbsorptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress for a skill
|
||||
*/
|
||||
getSkillProgress(skillId) {
|
||||
return this.skillProgress.get(skillId) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all skill progress
|
||||
*/
|
||||
getAllProgress() {
|
||||
return this.skillProgress;
|
||||
}
|
||||
}
|
||||
|
||||
69
src/components/Skills.js
Normal file
69
src/components/Skills.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class Skills extends Component {
|
||||
constructor() {
|
||||
super('Skills');
|
||||
this.activeSkills = []; // Array of skill IDs
|
||||
this.passiveSkills = []; // Array of passive skill IDs
|
||||
this.skillCooldowns = new Map(); // skillId -> remaining cooldown time
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a skill
|
||||
*/
|
||||
addSkill(skillId, isPassive = false) {
|
||||
if (isPassive) {
|
||||
if (!this.passiveSkills.includes(skillId)) {
|
||||
this.passiveSkills.push(skillId);
|
||||
}
|
||||
} else {
|
||||
if (!this.activeSkills.includes(skillId)) {
|
||||
this.activeSkills.push(skillId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity has a skill
|
||||
*/
|
||||
hasSkill(skillId) {
|
||||
return this.activeSkills.includes(skillId) ||
|
||||
this.passiveSkills.includes(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set skill cooldown
|
||||
*/
|
||||
setCooldown(skillId, duration) {
|
||||
this.skillCooldowns.set(skillId, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cooldowns
|
||||
*/
|
||||
updateCooldowns(deltaTime) {
|
||||
for (const [skillId, cooldown] of this.skillCooldowns.entries()) {
|
||||
const newCooldown = cooldown - deltaTime;
|
||||
if (newCooldown <= 0) {
|
||||
this.skillCooldowns.delete(skillId);
|
||||
} else {
|
||||
this.skillCooldowns.set(skillId, newCooldown);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if skill is on cooldown
|
||||
*/
|
||||
isOnCooldown(skillId) {
|
||||
return this.skillCooldowns.has(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining cooldown
|
||||
*/
|
||||
getCooldown(skillId) {
|
||||
return this.skillCooldowns.get(skillId) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
18
src/components/Sprite.js
Normal file
18
src/components/Sprite.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class Sprite extends Component {
|
||||
constructor(color = '#00ff96', width = 30, height = 30, shape = 'circle') {
|
||||
super('Sprite');
|
||||
this.color = color;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.shape = shape; // 'circle', 'rect', 'slime'
|
||||
this.alpha = 1.0;
|
||||
this.scale = 1.0;
|
||||
|
||||
// Animation properties
|
||||
this.animationTime = 0;
|
||||
this.morphAmount = 0; // For slime morphing
|
||||
}
|
||||
}
|
||||
|
||||
55
src/components/Stats.js
Normal file
55
src/components/Stats.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class Stats extends Component {
|
||||
constructor() {
|
||||
super('Stats');
|
||||
this.strength = 10; // Physical damage
|
||||
this.agility = 10; // Movement speed, attack speed
|
||||
this.intelligence = 10; // Magic damage, skill effectiveness
|
||||
this.constitution = 10; // Max HP, defense
|
||||
this.perception = 10; // Detection range, stealth detection
|
||||
|
||||
// Derived stats
|
||||
this.level = 1;
|
||||
this.experience = 0;
|
||||
this.experienceToNext = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add experience and handle level ups
|
||||
*/
|
||||
addExperience(amount) {
|
||||
this.experience += amount;
|
||||
let leveledUp = false;
|
||||
|
||||
while (this.experience >= this.experienceToNext) {
|
||||
this.experience -= this.experienceToNext;
|
||||
this.levelUp();
|
||||
leveledUp = true;
|
||||
}
|
||||
|
||||
return leveledUp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Level up - increase stats
|
||||
*/
|
||||
levelUp() {
|
||||
this.level++;
|
||||
this.strength += 2;
|
||||
this.agility += 2;
|
||||
this.intelligence += 2;
|
||||
this.constitution += 2;
|
||||
this.perception += 2;
|
||||
this.experienceToNext = Math.floor(this.experienceToNext * 1.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total stat points
|
||||
*/
|
||||
getTotalStats() {
|
||||
return this.strength + this.agility + this.intelligence +
|
||||
this.constitution + this.perception;
|
||||
}
|
||||
}
|
||||
|
||||
48
src/components/Stealth.js
Normal file
48
src/components/Stealth.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class Stealth extends Component {
|
||||
constructor() {
|
||||
super('Stealth');
|
||||
this.visibility = 1.0; // 0 = fully hidden, 1 = fully visible
|
||||
this.stealthType = 'slime'; // 'slime', 'beast', 'human'
|
||||
this.isStealthed = false;
|
||||
this.stealthLevel = 0; // 0-100
|
||||
this.detectionRadius = 100; // How far others can detect this entity
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter stealth mode
|
||||
*/
|
||||
enterStealth(type) {
|
||||
this.stealthType = type;
|
||||
this.isStealthed = true;
|
||||
this.visibility = 0.3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit stealth mode
|
||||
*/
|
||||
exitStealth() {
|
||||
this.isStealthed = false;
|
||||
this.visibility = 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stealth based on movement and actions
|
||||
*/
|
||||
updateStealth(isMoving, isInCombat) {
|
||||
if (isInCombat) {
|
||||
this.exitStealth();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isStealthed) {
|
||||
if (isMoving) {
|
||||
this.visibility = Math.min(1.0, this.visibility + 0.1);
|
||||
} else {
|
||||
this.visibility = Math.max(0.1, this.visibility - 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
src/components/Velocity.js
Normal file
11
src/components/Velocity.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Component } from '../core/Component.js';
|
||||
|
||||
export class Velocity extends Component {
|
||||
constructor(vx = 0, vy = 0) {
|
||||
super('Velocity');
|
||||
this.vx = vx;
|
||||
this.vy = vy;
|
||||
this.maxSpeed = 200;
|
||||
}
|
||||
}
|
||||
|
||||
14
src/core/Component.js
Normal file
14
src/core/Component.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Base Component class for ECS architecture
|
||||
* Components are pure data containers
|
||||
*/
|
||||
export class Component {
|
||||
constructor(type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
|
||||
139
src/core/Engine.js
Normal file
139
src/core/Engine.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { System } from './System.js';
|
||||
import { Entity } from './Entity.js';
|
||||
import { EventBus } from './EventBus.js';
|
||||
|
||||
/**
|
||||
* Main game engine - manages ECS, game loop, and systems
|
||||
*/
|
||||
export class Engine {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.entities = [];
|
||||
this.systems = [];
|
||||
this.events = new EventBus();
|
||||
this.running = false;
|
||||
this.lastTime = 0;
|
||||
|
||||
// Set canvas size
|
||||
this.canvas.width = 1024;
|
||||
this.canvas.height = 768;
|
||||
|
||||
// Game state
|
||||
this.deltaTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a system to the engine
|
||||
*/
|
||||
addSystem(system) {
|
||||
if (system instanceof System) {
|
||||
system.init(this);
|
||||
this.systems.push(system);
|
||||
// Sort by priority (lower priority runs first)
|
||||
this.systems.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event locally
|
||||
*/
|
||||
emit(event, data) {
|
||||
this.events.emit(event, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event
|
||||
*/
|
||||
on(event, callback) {
|
||||
return this.events.on(event, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and add an entity
|
||||
*/
|
||||
createEntity() {
|
||||
const entity = new Entity();
|
||||
this.entities.push(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an entity
|
||||
*/
|
||||
removeEntity(entity) {
|
||||
const index = this.entities.indexOf(entity);
|
||||
if (index > -1) {
|
||||
this.entities.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entities
|
||||
*/
|
||||
getEntities() {
|
||||
return this.entities.filter(e => e.active);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main game loop
|
||||
*/
|
||||
start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.lastTime = performance.now();
|
||||
this.gameLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the game loop
|
||||
*/
|
||||
stop() {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Game loop using requestAnimationFrame
|
||||
*/
|
||||
gameLoop = (currentTime = 0) => {
|
||||
if (!this.running) return;
|
||||
|
||||
// Calculate delta time
|
||||
this.deltaTime = (currentTime - this.lastTime) / 1000; // Convert to seconds
|
||||
this.lastTime = currentTime;
|
||||
|
||||
// Clamp delta time to prevent large jumps
|
||||
this.deltaTime = Math.min(this.deltaTime, 0.1);
|
||||
|
||||
// Update all systems
|
||||
const menuSystem = this.systems.find(s => s.name === 'MenuSystem');
|
||||
const gameState = menuSystem ? menuSystem.getGameState() : 'playing';
|
||||
const isPaused = gameState === 'paused' || gameState === 'start';
|
||||
|
||||
this.systems.forEach(system => {
|
||||
// Skip game systems if paused/start menu (but allow MenuSystem, UISystem, and RenderSystem)
|
||||
if (isPaused && system.name !== 'MenuSystem' && system.name !== 'UISystem' && system.name !== 'RenderSystem') {
|
||||
return;
|
||||
}
|
||||
system.update(this.deltaTime, this.entities);
|
||||
});
|
||||
|
||||
// Update input system's previous states at end of frame
|
||||
const inputSystem = this.systems.find(s => s.name === 'InputSystem');
|
||||
if (inputSystem && inputSystem.updatePreviousStates) {
|
||||
inputSystem.updatePreviousStates();
|
||||
}
|
||||
|
||||
// Continue loop
|
||||
requestAnimationFrame(this.gameLoop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the canvas
|
||||
*/
|
||||
clear() {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
58
src/core/Entity.js
Normal file
58
src/core/Entity.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Entity class - represents a game object with a unique ID
|
||||
* Entities are just containers for components
|
||||
*/
|
||||
export class Entity {
|
||||
static nextId = 0;
|
||||
|
||||
constructor() {
|
||||
this.id = Entity.nextId++;
|
||||
this.components = new Map();
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a component to this entity
|
||||
*/
|
||||
addComponent(component) {
|
||||
this.components.set(component.type, component);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a component by type
|
||||
*/
|
||||
getComponent(type) {
|
||||
return this.components.get(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity has a component
|
||||
*/
|
||||
hasComponent(type) {
|
||||
return this.components.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity has all specified components
|
||||
*/
|
||||
hasComponents(...types) {
|
||||
return types.every(type => this.components.has(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a component
|
||||
*/
|
||||
removeComponent(type) {
|
||||
this.components.delete(type);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all components
|
||||
*/
|
||||
getAllComponents() {
|
||||
return Array.from(this.components.values());
|
||||
}
|
||||
}
|
||||
|
||||
57
src/core/EventBus.js
Normal file
57
src/core/EventBus.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Lightweight EventBus for pub/sub communication between systems
|
||||
*/
|
||||
export const Events = {
|
||||
// Combat Events
|
||||
DAMAGE_DEALT: 'combat:damage_dealt',
|
||||
ENTITY_DIED: 'combat:entity_died',
|
||||
|
||||
// Evolution Events
|
||||
EVOLVED: 'evolution:evolved',
|
||||
MUTATION_GAINED: 'evolution:mutation_gained',
|
||||
|
||||
// Leveling Events
|
||||
EXP_GAINED: 'stats:exp_gained',
|
||||
LEVEL_UP: 'stats:level_up',
|
||||
|
||||
// Skill Events
|
||||
SKILL_LEARNED: 'skills:learned',
|
||||
SKILL_COOLDOWN_STARTED: 'skills:cooldown_started'
|
||||
};
|
||||
|
||||
export class EventBus {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event).push(callback);
|
||||
return () => this.off(event, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event
|
||||
*/
|
||||
off(event, callback) {
|
||||
if (!this.listeners.has(event)) return;
|
||||
const callbacks = this.listeners.get(event);
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event
|
||||
*/
|
||||
emit(event, data) {
|
||||
if (!this.listeners.has(event)) return;
|
||||
this.listeners.get(event).forEach(callback => callback(data));
|
||||
}
|
||||
}
|
||||
45
src/core/System.js
Normal file
45
src/core/System.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Base System class for ECS architecture
|
||||
* Systems contain logic that operates on entities with specific components
|
||||
*/
|
||||
export class System {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.requiredComponents = [];
|
||||
this.priority = 0; // Lower priority runs first
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entity matches this system's requirements
|
||||
*/
|
||||
matches(entity) {
|
||||
if (!entity.active) return false;
|
||||
return this.requiredComponents.every(componentType =>
|
||||
entity.hasComponent(componentType)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update method - override in subclasses
|
||||
*/
|
||||
update(deltaTime, entities) {
|
||||
// Filter entities that match this system's requirements
|
||||
const matchingEntities = entities.filter(entity => this.matches(entity));
|
||||
this.process(deltaTime, matchingEntities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process matching entities - override in subclasses
|
||||
*/
|
||||
process(_deltaTime, _entities) {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when system is added to engine
|
||||
*/
|
||||
init(engine) {
|
||||
this.engine = engine;
|
||||
}
|
||||
}
|
||||
|
||||
13
src/items/Item.js
Normal file
13
src/items/Item.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Base Item class
|
||||
*/
|
||||
export class Item {
|
||||
constructor(id, name, type = 'consumable') {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.type = type; // 'consumable', 'weapon', 'armor', 'accessory'
|
||||
this.description = '';
|
||||
this.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
53
src/items/ItemRegistry.js
Normal file
53
src/items/ItemRegistry.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Item } from './Item.js';
|
||||
|
||||
/**
|
||||
* Registry for all items in the game
|
||||
*/
|
||||
export class ItemRegistry {
|
||||
static items = new Map();
|
||||
|
||||
static {
|
||||
// Register items
|
||||
// Weapons
|
||||
this.register(this.createWeapon('iron_sword', 'Iron Sword', 15, 5));
|
||||
this.register(this.createWeapon('steel_claw', 'Steel Claw', 20, 3));
|
||||
|
||||
// Armor
|
||||
this.register(this.createArmor('leather_armor', 'Leather Armor', 10, 5));
|
||||
|
||||
// Consumables
|
||||
this.register(this.createConsumable('health_potion', 'Health Potion', 50));
|
||||
}
|
||||
|
||||
static register(item) {
|
||||
this.items.set(item.id, item);
|
||||
}
|
||||
|
||||
static get(id) {
|
||||
return this.items.get(id);
|
||||
}
|
||||
|
||||
static createWeapon(id, name, damage, speed) {
|
||||
const item = new Item(id, name, 'weapon');
|
||||
item.damage = damage;
|
||||
item.attackSpeed = speed;
|
||||
item.description = `Weapon: +${damage} damage, ${speed} speed`;
|
||||
return item;
|
||||
}
|
||||
|
||||
static createArmor(id, name, defense, hp) {
|
||||
const item = new Item(id, name, 'armor');
|
||||
item.defense = defense;
|
||||
item.maxHp = hp;
|
||||
item.description = `Armor: +${defense} defense, +${hp} HP`;
|
||||
return item;
|
||||
}
|
||||
|
||||
static createConsumable(id, name, healAmount) {
|
||||
const item = new Item(id, name, 'consumable');
|
||||
item.healAmount = healAmount;
|
||||
item.description = `Restores ${healAmount} HP`;
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
158
src/main.js
Normal file
158
src/main.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { Engine } from './core/Engine.js';
|
||||
import { InputSystem } from './systems/InputSystem.js';
|
||||
import { MovementSystem } from './systems/MovementSystem.js';
|
||||
import { PlayerControllerSystem } from './systems/PlayerControllerSystem.js';
|
||||
import { CombatSystem } from './systems/CombatSystem.js';
|
||||
import { AISystem } from './systems/AISystem.js';
|
||||
import { AbsorptionSystem } from './systems/AbsorptionSystem.js';
|
||||
import { SkillSystem } from './systems/SkillSystem.js';
|
||||
import { StealthSystem } from './systems/StealthSystem.js';
|
||||
import { ProjectileSystem } from './systems/ProjectileSystem.js';
|
||||
import { SkillEffectSystem } from './systems/SkillEffectSystem.js';
|
||||
import { HealthRegenerationSystem } from './systems/HealthRegenerationSystem.js';
|
||||
import { DeathSystem } from './systems/DeathSystem.js';
|
||||
import { MenuSystem } from './systems/MenuSystem.js';
|
||||
import { RenderSystem } from './systems/RenderSystem.js';
|
||||
import { UISystem } from './systems/UISystem.js';
|
||||
|
||||
// Components
|
||||
import { Position } from './components/Position.js';
|
||||
import { Velocity } from './components/Velocity.js';
|
||||
import { Sprite } from './components/Sprite.js';
|
||||
import { Health } from './components/Health.js';
|
||||
import { Stats } from './components/Stats.js';
|
||||
import { Evolution } from './components/Evolution.js';
|
||||
import { Skills } from './components/Skills.js';
|
||||
import { Inventory } from './components/Inventory.js';
|
||||
import { Combat } from './components/Combat.js';
|
||||
import { Stealth } from './components/Stealth.js';
|
||||
import { AI } from './components/AI.js';
|
||||
import { Absorbable } from './components/Absorbable.js';
|
||||
import { SkillProgress } from './components/SkillProgress.js';
|
||||
|
||||
// Initialize game
|
||||
const canvas = document.getElementById('game-canvas');
|
||||
if (!canvas) {
|
||||
console.error('Canvas element not found!');
|
||||
} else {
|
||||
const engine = new Engine(canvas);
|
||||
|
||||
// Add systems in order
|
||||
engine.addSystem(new MenuSystem(engine));
|
||||
engine.addSystem(new InputSystem());
|
||||
engine.addSystem(new PlayerControllerSystem());
|
||||
engine.addSystem(new StealthSystem());
|
||||
engine.addSystem(new AISystem());
|
||||
engine.addSystem(new MovementSystem());
|
||||
engine.addSystem(new CombatSystem());
|
||||
engine.addSystem(new ProjectileSystem());
|
||||
engine.addSystem(new AbsorptionSystem());
|
||||
engine.addSystem(new SkillSystem());
|
||||
engine.addSystem(new SkillEffectSystem());
|
||||
engine.addSystem(new HealthRegenerationSystem());
|
||||
engine.addSystem(new DeathSystem());
|
||||
engine.addSystem(new RenderSystem(engine));
|
||||
engine.addSystem(new UISystem(engine));
|
||||
|
||||
// Create player entity
|
||||
const player = engine.createEntity();
|
||||
player.addComponent(new Position(512, 384));
|
||||
player.addComponent(new Velocity(0, 0, 200));
|
||||
player.addComponent(new Sprite('#00ff96', 40, 40, 'slime'));
|
||||
player.addComponent(new Health(100));
|
||||
player.addComponent(new Stats());
|
||||
player.addComponent(new Evolution());
|
||||
|
||||
// Give player a starting skill so they can test it
|
||||
const playerSkills = new Skills();
|
||||
playerSkills.addSkill('slime_gun', false); // Add slime_gun as starting skill (basic slime ability)
|
||||
player.addComponent(playerSkills);
|
||||
|
||||
player.addComponent(new Inventory());
|
||||
player.addComponent(new Combat());
|
||||
player.addComponent(new Stealth());
|
||||
player.addComponent(new SkillProgress()); // Track skill learning progress
|
||||
|
||||
// Create creatures
|
||||
function createCreature(engine, x, y, type) {
|
||||
const creature = engine.createEntity();
|
||||
creature.addComponent(new Position(x, y));
|
||||
creature.addComponent(new Velocity(0, 0, 100));
|
||||
|
||||
let color, evolutionData, skills;
|
||||
|
||||
switch (type) {
|
||||
case 'humanoid':
|
||||
color = '#ff5555'; // Humanoid red
|
||||
evolutionData = { human: 10, beast: 0, slime: -2 };
|
||||
skills = ['fire_breath'];
|
||||
break;
|
||||
case 'beast':
|
||||
color = '#ffaa00'; // Beast orange
|
||||
evolutionData = { human: 0, beast: 10, slime: -2 };
|
||||
skills = ['pounce'];
|
||||
break;
|
||||
case 'elemental':
|
||||
color = '#00bfff';
|
||||
evolutionData = { human: 3, beast: 3, slime: 8 };
|
||||
skills = ['fire_breath'];
|
||||
break;
|
||||
default:
|
||||
color = '#888888';
|
||||
evolutionData = { human: 2, beast: 2, slime: 2 };
|
||||
skills = [];
|
||||
}
|
||||
|
||||
creature.addComponent(new Sprite(color, 25, 25, 'circle'));
|
||||
creature.addComponent(new Health(50 + Math.random() * 30));
|
||||
creature.addComponent(new Stats());
|
||||
creature.addComponent(new Combat());
|
||||
creature.addComponent(new AI('wander'));
|
||||
|
||||
const absorbable = new Absorbable();
|
||||
absorbable.setEvolutionData(evolutionData.human, evolutionData.beast, evolutionData.slime);
|
||||
skills.forEach(skill => absorbable.addSkill(skill, 0.3));
|
||||
creature.addComponent(absorbable);
|
||||
|
||||
return creature;
|
||||
}
|
||||
|
||||
// Spawn initial creatures
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const x = 100 + Math.random() * 824;
|
||||
const y = 100 + Math.random() * 568;
|
||||
const types = ['humanoid', 'beast', 'elemental'];
|
||||
const type = types[Math.floor(Math.random() * types.length)];
|
||||
createCreature(engine, x, y, type);
|
||||
}
|
||||
|
||||
// Spawn new creatures periodically
|
||||
setInterval(() => {
|
||||
const existingCreatures = engine.getEntities().filter(e =>
|
||||
e.hasComponent('AI') && e !== player
|
||||
);
|
||||
|
||||
if (existingCreatures.length < 10) {
|
||||
const x = 100 + Math.random() * 824;
|
||||
const y = 100 + Math.random() * 568;
|
||||
const types = ['humanoid', 'beast', 'elemental'];
|
||||
const type = types[Math.floor(Math.random() * types.length)];
|
||||
createCreature(engine, x, y, type);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Focus canvas for keyboard input
|
||||
canvas.focus();
|
||||
|
||||
// Start engine but MenuSystem will control when gameplay begins
|
||||
engine.start();
|
||||
|
||||
// Make engine globally available for debugging
|
||||
window.gameEngine = engine;
|
||||
window.player = player;
|
||||
|
||||
// Re-focus canvas on click
|
||||
canvas.addEventListener('click', () => {
|
||||
canvas.focus();
|
||||
});
|
||||
}
|
||||
32
src/skills/Skill.js
Normal file
32
src/skills/Skill.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Base Skill class
|
||||
*/
|
||||
export class Skill {
|
||||
constructor(id, name, cooldown = 2.0) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.cooldown = cooldown;
|
||||
this.description = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the skill
|
||||
* @param {Entity} caster - Entity using the skill
|
||||
* @param {Engine} engine - Game engine
|
||||
* @returns {boolean} - Whether skill was successfully activated
|
||||
*/
|
||||
activate(_caster, _engine) {
|
||||
// Override in subclasses
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if skill can be used
|
||||
*/
|
||||
canUse(caster, _engine) {
|
||||
const skills = caster.getComponent('Skills');
|
||||
if (!skills) return false;
|
||||
return !skills.isOnCooldown(this.id);
|
||||
}
|
||||
}
|
||||
36
src/skills/SkillRegistry.js
Normal file
36
src/skills/SkillRegistry.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { SlimeGun } from './skills/WaterGun.js'; // File still named WaterGun.js but class is SlimeGun
|
||||
import { FireBreath } from './skills/FireBreath.js';
|
||||
import { Pounce } from './skills/Pounce.js';
|
||||
import { StealthMode } from './skills/StealthMode.js';
|
||||
|
||||
/**
|
||||
* Registry for all skills in the game
|
||||
*/
|
||||
export class SkillRegistry {
|
||||
static skills = new Map();
|
||||
|
||||
static {
|
||||
// Register all skills
|
||||
this.register(new SlimeGun());
|
||||
this.register(new FireBreath());
|
||||
this.register(new Pounce());
|
||||
this.register(new StealthMode());
|
||||
}
|
||||
|
||||
static register(skill) {
|
||||
this.skills.set(skill.id, skill);
|
||||
}
|
||||
|
||||
static get(id) {
|
||||
return this.skills.get(id);
|
||||
}
|
||||
|
||||
static getAll() {
|
||||
return Array.from(this.skills.values());
|
||||
}
|
||||
|
||||
static has(id) {
|
||||
return this.skills.has(id);
|
||||
}
|
||||
}
|
||||
|
||||
83
src/skills/skills/FireBreath.js
Normal file
83
src/skills/skills/FireBreath.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Skill } from '../Skill.js';
|
||||
|
||||
export class FireBreath extends Skill {
|
||||
constructor() {
|
||||
super('fire_breath', 'Fire Breath', 3.0);
|
||||
this.description = 'Breathe fire in a cone';
|
||||
this.damage = 25;
|
||||
this.range = 150;
|
||||
this.coneAngle = Math.PI / 3; // 60 degrees
|
||||
this.duration = 0.5; // Animation duration
|
||||
}
|
||||
|
||||
activate(caster, engine) {
|
||||
if (!this.canUse(caster, engine)) return false;
|
||||
|
||||
const position = caster.getComponent('Position');
|
||||
const stats = caster.getComponent('Stats');
|
||||
const skills = caster.getComponent('Skills');
|
||||
const inputSystem = engine.systems.find(s => s.name === 'InputSystem');
|
||||
|
||||
if (!position || !skills) return false;
|
||||
|
||||
// Calculate direction from player to mouse
|
||||
let fireAngle = position.rotation;
|
||||
if (inputSystem) {
|
||||
const mouse = inputSystem.getMousePosition();
|
||||
const dx = mouse.x - position.x;
|
||||
const dy = mouse.y - position.y;
|
||||
if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) {
|
||||
fireAngle = Math.atan2(dy, dx);
|
||||
position.rotation = fireAngle;
|
||||
}
|
||||
}
|
||||
|
||||
skills.setCooldown(this.id, this.cooldown);
|
||||
|
||||
// Add visual effect
|
||||
const skillEffectSystem = engine.systems.find(s => s.name === 'SkillEffectSystem');
|
||||
if (skillEffectSystem) {
|
||||
skillEffectSystem.addEffect({
|
||||
type: 'fire_breath',
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
angle: fireAngle,
|
||||
range: this.range,
|
||||
coneAngle: this.coneAngle,
|
||||
lifetime: this.duration,
|
||||
time: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Damage all enemies in cone
|
||||
const entities = engine.getEntities();
|
||||
const damage = this.damage + (stats ? stats.intelligence * 0.5 : 0);
|
||||
|
||||
entities.forEach(entity => {
|
||||
if (entity.id === caster.id) return;
|
||||
if (!entity.hasComponent('Health')) return;
|
||||
|
||||
const targetPos = entity.getComponent('Position');
|
||||
if (!targetPos) return;
|
||||
|
||||
const dx = targetPos.x - position.x;
|
||||
const dy = targetPos.y - position.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance <= this.range) {
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const angleDiff = Math.abs(angle - fireAngle);
|
||||
const normalizedDiff = Math.abs(((angleDiff % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2));
|
||||
const minDiff = Math.min(normalizedDiff, Math.PI * 2 - normalizedDiff);
|
||||
|
||||
if (minDiff < this.coneAngle / 2) {
|
||||
const health = entity.getComponent('Health');
|
||||
health.takeDamage(damage);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
97
src/skills/skills/Pounce.js
Normal file
97
src/skills/skills/Pounce.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { Skill } from '../Skill.js';
|
||||
|
||||
export class Pounce extends Skill {
|
||||
constructor() {
|
||||
super('pounce', 'Pounce', 2.0);
|
||||
this.description = 'Leap forward and damage enemies';
|
||||
this.damage = 20;
|
||||
this.range = 100;
|
||||
this.dashSpeed = 400;
|
||||
this.dashDuration = 0.2; // How long the dash lasts
|
||||
}
|
||||
|
||||
activate(caster, engine) {
|
||||
if (!this.canUse(caster, engine)) return false;
|
||||
|
||||
const position = caster.getComponent('Position');
|
||||
const velocity = caster.getComponent('Velocity');
|
||||
const stats = caster.getComponent('Stats');
|
||||
const skills = caster.getComponent('Skills');
|
||||
const inputSystem = engine.systems.find(s => s.name === 'InputSystem');
|
||||
|
||||
if (!position || !velocity || !skills) return false;
|
||||
|
||||
// Calculate direction from player to mouse
|
||||
let dashAngle = position.rotation;
|
||||
if (inputSystem) {
|
||||
const mouse = inputSystem.getMousePosition();
|
||||
const dx = mouse.x - position.x;
|
||||
const dy = mouse.y - position.y;
|
||||
if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) {
|
||||
dashAngle = Math.atan2(dy, dx);
|
||||
position.rotation = dashAngle;
|
||||
}
|
||||
}
|
||||
|
||||
skills.setCooldown(this.id, this.cooldown);
|
||||
|
||||
// Store start position for effect
|
||||
const startX = position.x;
|
||||
const startY = position.y;
|
||||
|
||||
// Dash forward
|
||||
velocity.vx = Math.cos(dashAngle) * this.dashSpeed;
|
||||
velocity.vy = Math.sin(dashAngle) * this.dashSpeed;
|
||||
|
||||
// Add visual effect
|
||||
const skillEffectSystem = engine.systems.find(s => s.name === 'SkillEffectSystem');
|
||||
if (skillEffectSystem) {
|
||||
skillEffectSystem.addEffect({
|
||||
type: 'pounce',
|
||||
startX: startX,
|
||||
startY: startY,
|
||||
angle: dashAngle,
|
||||
speed: this.dashSpeed,
|
||||
lifetime: this.dashDuration,
|
||||
time: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Damage enemies at destination after dash
|
||||
setTimeout(() => {
|
||||
const entities = engine.getEntities();
|
||||
const damage = this.damage + (stats ? stats.strength * 0.4 : 0);
|
||||
|
||||
entities.forEach(entity => {
|
||||
if (entity.id === caster.id) return;
|
||||
if (!entity.hasComponent('Health')) return;
|
||||
|
||||
const targetPos = entity.getComponent('Position');
|
||||
if (!targetPos) return;
|
||||
|
||||
const dx = targetPos.x - position.x;
|
||||
const dy = targetPos.y - position.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance <= this.range) {
|
||||
const health = entity.getComponent('Health');
|
||||
health.takeDamage(damage);
|
||||
|
||||
// Add impact effect
|
||||
if (skillEffectSystem) {
|
||||
skillEffectSystem.addEffect({
|
||||
type: 'pounce_impact',
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
lifetime: 0.3,
|
||||
time: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, this.dashDuration * 1000);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
35
src/skills/skills/StealthMode.js
Normal file
35
src/skills/skills/StealthMode.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Skill } from '../Skill.js';
|
||||
|
||||
export class StealthMode extends Skill {
|
||||
constructor() {
|
||||
super('stealth_mode', 'Stealth Mode', 5.0);
|
||||
this.description = 'Enter stealth mode';
|
||||
this.duration = 10.0;
|
||||
}
|
||||
|
||||
activate(caster, engine) {
|
||||
if (!this.canUse(caster, engine)) return false;
|
||||
|
||||
const stealth = caster.getComponent('Stealth');
|
||||
const skills = caster.getComponent('Skills');
|
||||
const evolution = caster.getComponent('Evolution');
|
||||
|
||||
if (!stealth || !skills) return false;
|
||||
|
||||
skills.setCooldown(this.id, this.cooldown);
|
||||
|
||||
// Determine stealth type from evolution
|
||||
const form = evolution ? evolution.getDominantForm() : 'slime';
|
||||
stealth.enterStealth(form);
|
||||
|
||||
// Auto-exit after duration
|
||||
setTimeout(() => {
|
||||
if (stealth.isStealthed) {
|
||||
stealth.exitStealth();
|
||||
}
|
||||
}, this.duration * 1000);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
84
src/skills/skills/WaterGun.js
Normal file
84
src/skills/skills/WaterGun.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Skill } from '../Skill.js';
|
||||
import { Position } from '../../components/Position.js';
|
||||
import { Velocity } from '../../components/Velocity.js';
|
||||
import { Sprite } from '../../components/Sprite.js';
|
||||
import { Health } from '../../components/Health.js';
|
||||
|
||||
export class SlimeGun extends Skill {
|
||||
constructor() {
|
||||
super('slime_gun', 'Slime Gun', 1.0);
|
||||
this.description = 'Shoot a blob of slime at enemies (costs 1 HP)';
|
||||
this.damage = 15;
|
||||
this.range = 800; // Long range for a gun
|
||||
this.speed = 600; // Faster projectile
|
||||
this.hpCost = 1;
|
||||
}
|
||||
|
||||
activate(caster, engine) {
|
||||
if (!this.canUse(caster, engine)) return false;
|
||||
|
||||
const position = caster.getComponent('Position');
|
||||
const health = caster.getComponent('Health');
|
||||
const stats = caster.getComponent('Stats');
|
||||
const skills = caster.getComponent('Skills');
|
||||
const inputSystem = engine.systems.find(s => s.name === 'InputSystem');
|
||||
|
||||
if (!position || !skills || !health) return false;
|
||||
|
||||
// Check if we have enough HP
|
||||
if (health.hp <= this.hpCost) {
|
||||
return false; // Can't use if it would kill us
|
||||
}
|
||||
|
||||
// Calculate direction from player to mouse
|
||||
let shootAngle = position.rotation;
|
||||
if (inputSystem) {
|
||||
const mouse = inputSystem.getMousePosition();
|
||||
const dx = mouse.x - position.x;
|
||||
const dy = mouse.y - position.y;
|
||||
if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) {
|
||||
shootAngle = Math.atan2(dy, dx);
|
||||
}
|
||||
}
|
||||
|
||||
// Cost HP (sacrificing slime)
|
||||
health.takeDamage(this.hpCost);
|
||||
|
||||
// Set cooldown
|
||||
skills.setCooldown(this.id, this.cooldown);
|
||||
|
||||
// Create projectile (slime blob)
|
||||
const projectile = engine.createEntity();
|
||||
const startX = position.x;
|
||||
const startY = position.y;
|
||||
projectile.addComponent(new Position(startX, startY));
|
||||
|
||||
// Create velocity with high maxSpeed for projectiles
|
||||
const projectileVelocity = new Velocity(
|
||||
Math.cos(shootAngle) * this.speed,
|
||||
Math.sin(shootAngle) * this.speed
|
||||
);
|
||||
projectileVelocity.maxSpeed = this.speed * 2; // Allow projectiles to move fast
|
||||
projectile.addComponent(projectileVelocity);
|
||||
// Slime-colored projectile
|
||||
projectile.addComponent(new Sprite('#00ff96', 10, 10, 'slime'));
|
||||
|
||||
// Projectile has temporary health for collision detection
|
||||
const projectileHealth = new Health(1);
|
||||
projectileHealth.isProjectile = true;
|
||||
projectile.addComponent(projectileHealth);
|
||||
|
||||
// Store projectile data
|
||||
projectile.damage = this.damage + (stats ? stats.intelligence * 0.3 : 0);
|
||||
projectile.owner = caster.id;
|
||||
projectile.startX = startX;
|
||||
projectile.startY = startY;
|
||||
projectile.maxRange = this.range;
|
||||
projectile.speed = this.speed;
|
||||
// Lifetime as backup (should be longer than range travel time)
|
||||
projectile.lifetime = (this.range / this.speed) + 1.0;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
205
src/systems/AISystem.js
Normal file
205
src/systems/AISystem.js
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { System } from '../core/System.js';
|
||||
import { GameConfig } from '../GameConfig.js';
|
||||
|
||||
export class AISystem extends System {
|
||||
constructor() {
|
||||
super('AISystem');
|
||||
this.requiredComponents = ['Position', 'Velocity', 'AI'];
|
||||
this.priority = 15;
|
||||
}
|
||||
|
||||
process(deltaTime, entities) {
|
||||
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
|
||||
const player = playerController ? playerController.getPlayerEntity() : null;
|
||||
const playerPos = player?.getComponent('Position');
|
||||
const config = GameConfig.AI;
|
||||
|
||||
entities.forEach(entity => {
|
||||
const ai = entity.getComponent('AI');
|
||||
const position = entity.getComponent('Position');
|
||||
const velocity = entity.getComponent('Velocity');
|
||||
const _stealth = entity.getComponent('Stealth');
|
||||
|
||||
if (!ai || !position || !velocity) return;
|
||||
|
||||
// Update wander timer
|
||||
ai.wanderChangeTime += deltaTime;
|
||||
|
||||
// Detect player
|
||||
if (playerPos) {
|
||||
const dx = playerPos.x - position.x;
|
||||
const dy = playerPos.y - position.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Update awareness based on distance and player stealth
|
||||
const playerStealth = player?.getComponent('Stealth');
|
||||
const playerVisibility = playerStealth ? playerStealth.visibility : 1.0;
|
||||
|
||||
if (distance < ai.alertRadius) {
|
||||
const detectionChance = (1 - distance / ai.alertRadius) * playerVisibility;
|
||||
ai.updateAwareness(detectionChance * deltaTime * config.awarenessGainMultiplier);
|
||||
} else {
|
||||
ai.updateAwareness(-deltaTime * config.awarenessLossRate); // Lose awareness over time
|
||||
}
|
||||
|
||||
// Biological Reputation Logic
|
||||
const playerEvolution = player?.getComponent('Evolution');
|
||||
const playerForm = playerEvolution ? playerEvolution.getDominantForm() : 'slime';
|
||||
const entityType = entity.getComponent('Sprite')?.color === '#ffaa00' ? 'beast' :
|
||||
entity.getComponent('Sprite')?.color === '#ff5555' ? 'humanoid' : 'other';
|
||||
|
||||
// Check if player is "one of us" or "too scary"
|
||||
let isPassive = false;
|
||||
let shouldFlee = false;
|
||||
|
||||
if (entityType === 'humanoid' && playerForm === 'human') {
|
||||
// Humanoids are passive to human-form slime unless awareness is maxed (hostile action taken)
|
||||
if (ai.awareness < config.passiveAwarenessThreshold) isPassive = true;
|
||||
} else if (entityType === 'beast' && playerForm === 'beast') {
|
||||
// Beasts might flee from a dominant beast player
|
||||
const playerStats = player?.getComponent('Stats');
|
||||
const entityStats = entity.getComponent('Stats');
|
||||
if (playerStats && entityStats && playerStats.level > entityStats.level) {
|
||||
shouldFlee = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Behavior based on awareness, reputation, and distance
|
||||
if (shouldFlee && ai.awareness > config.fleeAwarenessThreshold) {
|
||||
ai.setBehavior('flee');
|
||||
ai.state = 'fleeing';
|
||||
ai.setTarget(player.id);
|
||||
} else if (isPassive) {
|
||||
if (ai.behaviorType === 'chase' || ai.behaviorType === 'combat') {
|
||||
ai.setBehavior('wander');
|
||||
ai.state = 'idle';
|
||||
ai.clearTarget();
|
||||
}
|
||||
} else if (ai.awareness > config.detectionAwarenessThreshold && distance < ai.chaseRadius) {
|
||||
if (ai.behaviorType !== 'flee') {
|
||||
// Check if in attack range - if so, use combat behavior
|
||||
const combat = entity.getComponent('Combat');
|
||||
if (combat && distance <= combat.attackRange) {
|
||||
ai.setBehavior('combat');
|
||||
ai.state = 'combat';
|
||||
} else {
|
||||
ai.setBehavior('chase');
|
||||
ai.state = 'chasing';
|
||||
}
|
||||
ai.setTarget(player.id);
|
||||
}
|
||||
} else if (ai.awareness < 0.3) {
|
||||
if (ai.behaviorType === 'chase' || ai.behaviorType === 'combat') {
|
||||
ai.setBehavior('wander');
|
||||
ai.state = 'idle';
|
||||
ai.clearTarget();
|
||||
}
|
||||
} else if (ai.behaviorType === 'chase') {
|
||||
// Update from chase to combat if in range
|
||||
const combat = entity.getComponent('Combat');
|
||||
if (combat && distance <= combat.attackRange) {
|
||||
ai.setBehavior('combat');
|
||||
ai.state = 'combat';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute behavior
|
||||
switch (ai.behaviorType) {
|
||||
case 'wander':
|
||||
this.wander(entity, ai, velocity, deltaTime);
|
||||
break;
|
||||
case 'chase':
|
||||
this.chase(entity, ai, velocity, position, playerPos);
|
||||
break;
|
||||
case 'flee':
|
||||
this.flee(entity, ai, velocity, position, playerPos);
|
||||
break;
|
||||
case 'combat':
|
||||
this.combat(entity, ai, velocity, position, playerPos);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wander(entity, ai, velocity, _deltaTime) {
|
||||
ai.state = 'moving';
|
||||
|
||||
// Change direction periodically
|
||||
if (ai.wanderChangeTime >= ai.wanderChangeInterval) {
|
||||
ai.wanderDirection = Math.random() * Math.PI * 2;
|
||||
ai.wanderChangeTime = 0;
|
||||
ai.wanderChangeInterval = 1 + Math.random() * 2;
|
||||
}
|
||||
|
||||
velocity.vx = Math.cos(ai.wanderDirection) * ai.wanderSpeed;
|
||||
velocity.vy = Math.sin(ai.wanderDirection) * ai.wanderSpeed;
|
||||
}
|
||||
|
||||
chase(entity, ai, velocity, position, targetPos) {
|
||||
if (!targetPos) return;
|
||||
|
||||
const dx = targetPos.x - position.x;
|
||||
const dy = targetPos.y - position.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Check if we should switch to combat
|
||||
const combat = entity.getComponent('Combat');
|
||||
if (combat && distance <= combat.attackRange) {
|
||||
ai.setBehavior('combat');
|
||||
ai.state = 'combat';
|
||||
return;
|
||||
}
|
||||
|
||||
ai.state = 'chasing';
|
||||
if (distance > 0.1) {
|
||||
const speed = ai.wanderSpeed * 1.5;
|
||||
velocity.vx = (dx / distance) * speed;
|
||||
velocity.vy = (dy / distance) * speed;
|
||||
} else {
|
||||
velocity.vx = 0;
|
||||
velocity.vy = 0;
|
||||
}
|
||||
}
|
||||
|
||||
flee(entity, ai, velocity, position, targetPos) {
|
||||
if (!targetPos) return;
|
||||
|
||||
ai.state = 'fleeing';
|
||||
const dx = position.x - targetPos.x;
|
||||
const dy = position.y - targetPos.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance > 0.1) {
|
||||
const speed = ai.wanderSpeed * 1.2;
|
||||
velocity.vx = (dx / distance) * speed;
|
||||
velocity.vy = (dy / distance) * speed;
|
||||
}
|
||||
}
|
||||
|
||||
combat(entity, ai, velocity, position, targetPos) {
|
||||
if (!targetPos) return;
|
||||
|
||||
ai.state = 'attacking';
|
||||
// Stop moving when in combat range - let CombatSystem handle attacks
|
||||
const dx = targetPos.x - position.x;
|
||||
const dy = targetPos.y - position.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
const combat = entity.getComponent('Combat');
|
||||
if (combat && distance > combat.attackRange) {
|
||||
// Move closer if out of range
|
||||
const speed = ai.wanderSpeed;
|
||||
velocity.vx = (dx / distance) * speed;
|
||||
velocity.vy = (dy / distance) * speed;
|
||||
} else {
|
||||
// Stop and face target
|
||||
velocity.vx *= 0.5;
|
||||
velocity.vy *= 0.5;
|
||||
if (position) {
|
||||
position.rotation = Math.atan2(dy, dx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
176
src/systems/AbsorptionSystem.js
Normal file
176
src/systems/AbsorptionSystem.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { System } from '../core/System.js';
|
||||
import { GameConfig } from '../GameConfig.js';
|
||||
import { Events } from '../core/EventBus.js';
|
||||
|
||||
export class AbsorptionSystem extends System {
|
||||
constructor() {
|
||||
super('AbsorptionSystem');
|
||||
this.requiredComponents = ['Position', 'Absorbable'];
|
||||
this.priority = 25;
|
||||
this.absorptionEffects = []; // Visual effects
|
||||
}
|
||||
|
||||
process(deltaTime, _entities) {
|
||||
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
|
||||
const player = playerController ? playerController.getPlayerEntity() : null;
|
||||
|
||||
if (!player) return;
|
||||
|
||||
const playerPos = player.getComponent('Position');
|
||||
const playerEvolution = player.getComponent('Evolution');
|
||||
const playerSkills = player.getComponent('Skills');
|
||||
const playerStats = player.getComponent('Stats');
|
||||
const skillProgress = player.getComponent('SkillProgress');
|
||||
|
||||
if (!playerPos || !playerEvolution) return;
|
||||
|
||||
// Get ALL entities (including inactive ones) for absorption check
|
||||
const allEntities = this.engine.entities; // Get raw entities array, not filtered
|
||||
|
||||
const config = GameConfig.Absorption;
|
||||
|
||||
// Check for absorbable entities near player
|
||||
allEntities.forEach(entity => {
|
||||
if (entity === player) return;
|
||||
// Allow inactive entities if they're dead and absorbable
|
||||
if (!entity.active) {
|
||||
const health = entity.getComponent('Health');
|
||||
const absorbable = entity.getComponent('Absorbable');
|
||||
// Only process inactive entities if they're dead and not yet absorbed
|
||||
if (!health || !health.isDead() || !absorbable || absorbable.absorbed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!entity.hasComponent('Absorbable')) return;
|
||||
if (!entity.hasComponent('Health')) return;
|
||||
|
||||
const absorbable = entity.getComponent('Absorbable');
|
||||
const health = entity.getComponent('Health');
|
||||
const entityPos = entity.getComponent('Position');
|
||||
|
||||
if (!entityPos) return;
|
||||
|
||||
// Check if creature is dead and in absorption range
|
||||
if (health.isDead() && !absorbable.absorbed) {
|
||||
const dx = playerPos.x - entityPos.x;
|
||||
const dy = playerPos.y - entityPos.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance <= config.range) {
|
||||
this.absorbEntity(player, entity, absorbable, playerEvolution, playerSkills, playerStats, skillProgress);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update visual effects
|
||||
this.updateEffects(deltaTime);
|
||||
}
|
||||
|
||||
absorbEntity(player, entity, absorbable, evolution, skills, stats, skillProgress) {
|
||||
if (absorbable.absorbed) return;
|
||||
|
||||
absorbable.absorbed = true;
|
||||
const entityPos = entity.getComponent('Position');
|
||||
const health = player.getComponent('Health');
|
||||
const config = GameConfig.Absorption;
|
||||
|
||||
// Add evolution points
|
||||
evolution.addEvolution(
|
||||
absorbable.evolutionData.human,
|
||||
absorbable.evolutionData.beast,
|
||||
absorbable.evolutionData.slime
|
||||
);
|
||||
|
||||
// Track skill progress (need to absorb multiple times to learn)
|
||||
// Always track progress for ALL skills the enemy has, regardless of roll
|
||||
if (skillProgress && absorbable.skillsGranted && absorbable.skillsGranted.length > 0) {
|
||||
absorbable.skillsGranted.forEach(skill => {
|
||||
// Always add progress when absorbing an enemy with this skill
|
||||
const currentProgress = skillProgress.addSkillProgress(skill.id);
|
||||
const required = skillProgress.requiredAbsorptions;
|
||||
|
||||
// If we've absorbed enough, learn the skill
|
||||
if (currentProgress >= required && !skills.hasSkill(skill.id)) {
|
||||
skills.addSkill(skill.id, false);
|
||||
this.engine.emit(Events.SKILL_LEARNED, { id: skill.id });
|
||||
console.log(`Learned skill: ${skill.id}!`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Heal from absorption (slime recovers by consuming)
|
||||
if (health) {
|
||||
const healPercent = config.healPercentMin + Math.random() * (config.healPercentMax - config.healPercentMin);
|
||||
const healAmount = health.maxHp * healPercent;
|
||||
health.heal(healAmount);
|
||||
}
|
||||
|
||||
// Check for mutation
|
||||
if (absorbable.shouldMutate() && stats) {
|
||||
this.applyMutation(stats);
|
||||
evolution.checkMutations(stats, this.engine);
|
||||
}
|
||||
|
||||
// Visual effect
|
||||
if (entityPos) {
|
||||
this.addAbsorptionEffect(entityPos.x, entityPos.y);
|
||||
}
|
||||
|
||||
// Mark as absorbed - DeathSystem will handle removal after absorption window
|
||||
// Don't remove immediately, let DeathSystem handle it
|
||||
}
|
||||
|
||||
applyMutation(stats) {
|
||||
// Random stat mutation
|
||||
const mutations = [
|
||||
{ stat: 'strength', amount: 5 },
|
||||
{ stat: 'agility', amount: 5 },
|
||||
{ stat: 'intelligence', amount: 5 },
|
||||
{ stat: 'constitution', amount: 5 },
|
||||
{ stat: 'perception', amount: 5 },
|
||||
];
|
||||
|
||||
const mutation = mutations[Math.floor(Math.random() * mutations.length)];
|
||||
stats[mutation.stat] += mutation.amount;
|
||||
|
||||
// Could also add negative mutations
|
||||
if (Math.random() < 0.3) {
|
||||
const negativeStat = mutations[Math.floor(Math.random() * mutations.length)];
|
||||
stats[negativeStat.stat] = Math.max(1, stats[negativeStat.stat] - 2);
|
||||
}
|
||||
}
|
||||
|
||||
addAbsorptionEffect(x, y) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
this.absorptionEffects.push({
|
||||
x,
|
||||
y,
|
||||
vx: (Math.random() - 0.5) * 200,
|
||||
vy: (Math.random() - 0.5) * 200,
|
||||
lifetime: 0.5 + Math.random() * 0.5,
|
||||
size: 3 + Math.random() * 5,
|
||||
color: `hsl(${120 + Math.random() * 60}, 100%, 50%)`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateEffects(deltaTime) {
|
||||
for (let i = this.absorptionEffects.length - 1; i >= 0; i--) {
|
||||
const effect = this.absorptionEffects[i];
|
||||
effect.x += effect.vx * deltaTime;
|
||||
effect.y += effect.vy * deltaTime;
|
||||
effect.lifetime -= deltaTime;
|
||||
effect.vx *= 0.95;
|
||||
effect.vy *= 0.95;
|
||||
|
||||
if (effect.lifetime <= 0) {
|
||||
this.absorptionEffects.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getEffects() {
|
||||
return this.absorptionEffects;
|
||||
}
|
||||
}
|
||||
|
||||
190
src/systems/CombatSystem.js
Normal file
190
src/systems/CombatSystem.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { System } from '../core/System.js';
|
||||
import { GameConfig } from '../GameConfig.js';
|
||||
import { Events } from '../core/EventBus.js';
|
||||
|
||||
export class CombatSystem extends System {
|
||||
constructor() {
|
||||
super('CombatSystem');
|
||||
this.requiredComponents = ['Position', 'Combat', 'Health'];
|
||||
this.priority = 20;
|
||||
}
|
||||
|
||||
process(deltaTime, entities) {
|
||||
// Update combat cooldowns
|
||||
entities.forEach(entity => {
|
||||
const combat = entity.getComponent('Combat');
|
||||
if (combat) {
|
||||
combat.update(deltaTime);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle player attacks
|
||||
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
|
||||
const player = playerController ? playerController.getPlayerEntity() : null;
|
||||
|
||||
if (player && player.hasComponent('Combat')) {
|
||||
this.handlePlayerCombat(player, deltaTime);
|
||||
}
|
||||
|
||||
// Handle creature attacks
|
||||
const creatures = entities.filter(e =>
|
||||
e.hasComponent('AI') &&
|
||||
e.hasComponent('Combat') &&
|
||||
e !== player
|
||||
);
|
||||
|
||||
creatures.forEach(creature => {
|
||||
this.handleCreatureCombat(creature, player, deltaTime);
|
||||
});
|
||||
|
||||
// Check for collisions and apply damage
|
||||
this.processCombatCollisions(entities, deltaTime);
|
||||
}
|
||||
|
||||
handlePlayerCombat(player, _deltaTime) {
|
||||
const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem');
|
||||
const combat = player.getComponent('Combat');
|
||||
const position = player.getComponent('Position');
|
||||
|
||||
if (!inputSystem || !combat || !position) return;
|
||||
|
||||
const currentTime = Date.now() / 1000;
|
||||
|
||||
// Attack on mouse click or space (use justPressed to prevent spam)
|
||||
const mouseClick = inputSystem.isMouseButtonJustPressed(0);
|
||||
const spacePress = inputSystem.isKeyJustPressed(' ') || inputSystem.isKeyJustPressed('space');
|
||||
|
||||
if ((mouseClick || spacePress) && combat.canAttack(currentTime)) {
|
||||
// Calculate attack direction from player to mouse
|
||||
const mouse = inputSystem.getMousePosition();
|
||||
const dx = mouse.x - position.x;
|
||||
const dy = mouse.y - position.y;
|
||||
const attackAngle = Math.atan2(dy, dx);
|
||||
|
||||
// Update player rotation to face attack direction
|
||||
position.rotation = attackAngle;
|
||||
|
||||
combat.attack(currentTime, attackAngle);
|
||||
|
||||
// Check for nearby enemies to damage
|
||||
this.performAttack(player, combat, position);
|
||||
}
|
||||
}
|
||||
|
||||
handleCreatureCombat(creature, player, _deltaTime) {
|
||||
const ai = creature.getComponent('AI');
|
||||
const combat = creature.getComponent('Combat');
|
||||
const position = creature.getComponent('Position');
|
||||
const playerPos = player?.getComponent('Position');
|
||||
|
||||
if (!ai || !combat || !position) return;
|
||||
|
||||
// Attack player if in range and aware (check both combat state and chase behavior)
|
||||
if (playerPos && ai.awareness > 0.5 && (ai.state === 'combat' || ai.behaviorType === 'combat' || (ai.behaviorType === 'chase' && ai.awareness > 0.7))) {
|
||||
const dx = playerPos.x - position.x;
|
||||
const dy = playerPos.y - position.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance <= combat.attackRange) {
|
||||
const currentTime = Date.now() / 1000;
|
||||
if (combat.canAttack(currentTime)) {
|
||||
const angle = Math.atan2(dy, dx);
|
||||
combat.attack(currentTime, angle);
|
||||
this.performAttack(creature, combat, position);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
performAttack(attacker, combat, attackerPos) {
|
||||
const entities = this.engine.getEntities();
|
||||
const stats = attacker.getComponent('Stats');
|
||||
const _baseDamage = stats ?
|
||||
(combat.attackDamage + stats.strength * 0.5) :
|
||||
combat.attackDamage;
|
||||
|
||||
entities.forEach(target => {
|
||||
if (target === attacker) return;
|
||||
if (!target.hasComponent('Health')) return;
|
||||
|
||||
const targetPos = target.getComponent('Position');
|
||||
if (!targetPos) return;
|
||||
|
||||
// Check if in attack range and angle
|
||||
const dx = targetPos.x - attackerPos.x;
|
||||
const dy = targetPos.y - attackerPos.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance <= combat.attackRange) {
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const angleDiff = Math.abs(angle - combat.attackDirection);
|
||||
const normalizedDiff = Math.abs(((angleDiff % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2));
|
||||
const minDiff = Math.min(normalizedDiff, Math.PI * 2 - normalizedDiff);
|
||||
|
||||
// Attack arc
|
||||
const attackArc = GameConfig.Combat.defaultAttackArc;
|
||||
if (minDiff < attackArc) {
|
||||
const health = target.getComponent('Health');
|
||||
const config = GameConfig.Combat;
|
||||
const stats = attacker.getComponent('Stats');
|
||||
const baseDamage = stats ? (combat.attackDamage + stats.strength * 0.5) : combat.attackDamage;
|
||||
|
||||
// Defense bonus from Hardened Shell
|
||||
let finalDamage = baseDamage;
|
||||
const targetEvolution = target.getComponent('Evolution');
|
||||
|
||||
if (targetEvolution && targetEvolution.mutationEffects.hardenedShell) {
|
||||
finalDamage *= config.hardenedShellReduction;
|
||||
}
|
||||
|
||||
const actualDamage = health.takeDamage(finalDamage);
|
||||
|
||||
// Emit event for UI/VFX
|
||||
this.engine.emit(Events.DAMAGE_DEALT, {
|
||||
x: targetPos.x,
|
||||
y: targetPos.y,
|
||||
value: actualDamage,
|
||||
color: '#ffffff'
|
||||
});
|
||||
|
||||
// Damage reflection from Electric Skin
|
||||
if (targetEvolution && targetEvolution.mutationEffects.electricSkin) {
|
||||
const attackerHealth = attacker.getComponent('Health');
|
||||
if (attackerHealth) {
|
||||
const reflectedDamage = actualDamage * config.damageReflectionPercent;
|
||||
attackerHealth.takeDamage(reflectedDamage);
|
||||
|
||||
this.engine.emit(Events.DAMAGE_DEALT, {
|
||||
x: attackerPos.x,
|
||||
y: attackerPos.y,
|
||||
value: reflectedDamage,
|
||||
color: '#00ffff'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If target is dead, emit event
|
||||
if (health.isDead()) {
|
||||
this.engine.emit(Events.ENTITY_DIED, { entity: target });
|
||||
target.active = false;
|
||||
}
|
||||
|
||||
// Apply knockback
|
||||
const velocity = target.getComponent('Velocity');
|
||||
if (velocity) {
|
||||
const knockbackPower = config.knockbackPower;
|
||||
const kx = Math.cos(angle) * knockbackPower;
|
||||
const ky = Math.sin(angle) * knockbackPower;
|
||||
velocity.vx += kx;
|
||||
velocity.vy += ky;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
processCombatCollisions(_entities, _deltaTime) {
|
||||
// This can be expanded for projectile collisions, area effects, etc.
|
||||
}
|
||||
}
|
||||
|
||||
56
src/systems/DeathSystem.js
Normal file
56
src/systems/DeathSystem.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { System } from '../core/System.js';
|
||||
|
||||
/**
|
||||
* System to handle entity death - removes dead entities immediately
|
||||
*/
|
||||
export class DeathSystem extends System {
|
||||
constructor() {
|
||||
super('DeathSystem');
|
||||
this.requiredComponents = ['Health'];
|
||||
this.priority = 50; // Run after absorption (absorption is priority 25)
|
||||
}
|
||||
|
||||
update(deltaTime, _entities) {
|
||||
// Override to check ALL entities, not just active ones
|
||||
// Get all entities including inactive ones to check dead entities
|
||||
const allEntities = this.engine.entities;
|
||||
this.process(deltaTime, allEntities);
|
||||
}
|
||||
|
||||
process(deltaTime, allEntities) {
|
||||
allEntities.forEach(entity => {
|
||||
const health = entity.getComponent('Health');
|
||||
if (!health) return;
|
||||
|
||||
// Check if entity is dead
|
||||
if (health.isDead()) {
|
||||
// Don't remove player
|
||||
const evolution = entity.getComponent('Evolution');
|
||||
if (evolution) return; // Player has Evolution component
|
||||
|
||||
// Mark as inactive immediately so it stops being processed by other systems
|
||||
if (entity.active) {
|
||||
entity.active = false;
|
||||
entity.deathTime = Date.now(); // Set death time when first marked dead
|
||||
}
|
||||
|
||||
// Check if it's absorbable - if so, give a short window for absorption
|
||||
const absorbable = entity.getComponent('Absorbable');
|
||||
if (absorbable && !absorbable.absorbed) {
|
||||
// Give 3 seconds for player to absorb, then remove
|
||||
const timeSinceDeath = (Date.now() - entity.deathTime) / 1000;
|
||||
if (timeSinceDeath > 3.0) {
|
||||
this.engine.removeEntity(entity);
|
||||
}
|
||||
} else {
|
||||
// Not absorbable or already absorbed - remove after short delay
|
||||
const timeSinceDeath = (Date.now() - entity.deathTime) / 1000;
|
||||
if (timeSinceDeath > 0.5) {
|
||||
this.engine.removeEntity(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
27
src/systems/HealthRegenerationSystem.js
Normal file
27
src/systems/HealthRegenerationSystem.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { System } from '../core/System.js';
|
||||
|
||||
/**
|
||||
* System to handle health regeneration
|
||||
*/
|
||||
export class HealthRegenerationSystem extends System {
|
||||
constructor() {
|
||||
super('HealthRegenerationSystem');
|
||||
this.requiredComponents = ['Health'];
|
||||
this.priority = 35;
|
||||
}
|
||||
|
||||
process(deltaTime, entities) {
|
||||
entities.forEach(entity => {
|
||||
const health = entity.getComponent('Health');
|
||||
if (!health || health.regeneration <= 0) return;
|
||||
|
||||
// Regenerate health over time
|
||||
// Only regenerate if not recently damaged (5 seconds)
|
||||
const timeSinceDamage = (Date.now() - health.lastDamageTime) / 1000;
|
||||
if (timeSinceDamage > 5) {
|
||||
health.heal(health.regeneration * deltaTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
153
src/systems/InputSystem.js
Normal file
153
src/systems/InputSystem.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { System } from '../core/System.js';
|
||||
|
||||
export class InputSystem extends System {
|
||||
constructor() {
|
||||
super('InputSystem');
|
||||
this.requiredComponents = []; // No required components - handles input globally
|
||||
this.priority = 0; // Run first
|
||||
|
||||
this.keys = {};
|
||||
this.keysPrevious = {}; // Track previous frame key states
|
||||
this.mouse = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
buttons: {},
|
||||
buttonsPrevious: {}
|
||||
};
|
||||
}
|
||||
|
||||
init(engine) {
|
||||
super.init(engine);
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
const code = e.code.toLowerCase();
|
||||
|
||||
// Store by key name
|
||||
this.keys[key] = true;
|
||||
this.keys[code] = true;
|
||||
|
||||
// Handle special keys
|
||||
if (key === ' ') {
|
||||
this.keys['space'] = true;
|
||||
}
|
||||
if (code === 'space') {
|
||||
this.keys['space'] = true;
|
||||
}
|
||||
|
||||
// Arrow keys
|
||||
if (code === 'arrowup') this.keys['arrowup'] = true;
|
||||
if (code === 'arrowdown') this.keys['arrowdown'] = true;
|
||||
if (code === 'arrowleft') this.keys['arrowleft'] = true;
|
||||
if (code === 'arrowright') this.keys['arrowright'] = true;
|
||||
|
||||
// Prevent default for game keys
|
||||
if ([' ', 'w', 'a', 's', 'd', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
const code = e.code.toLowerCase();
|
||||
|
||||
this.keys[key] = false;
|
||||
this.keys[code] = false;
|
||||
|
||||
// Handle special keys
|
||||
if (key === ' ') {
|
||||
this.keys['space'] = false;
|
||||
}
|
||||
if (code === 'space') {
|
||||
this.keys['space'] = false;
|
||||
}
|
||||
|
||||
// Arrow keys
|
||||
if (code === 'arrowup') this.keys['arrowup'] = false;
|
||||
if (code === 'arrowdown') this.keys['arrowdown'] = false;
|
||||
if (code === 'arrowleft') this.keys['arrowleft'] = false;
|
||||
if (code === 'arrowright') this.keys['arrowright'] = false;
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (this.engine && this.engine.canvas) {
|
||||
const canvas = this.engine.canvas;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
this.mouse.x = e.clientX - rect.left;
|
||||
this.mouse.y = e.clientY - rect.top;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mousedown', (e) => {
|
||||
this.mouse.buttons[e.button] = true;
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
this.mouse.buttons[e.button] = false;
|
||||
});
|
||||
}
|
||||
|
||||
process(_deltaTime, _entities) {
|
||||
// Don't update previous states here - that happens at end of frame
|
||||
// This allows other systems to check isKeyJustPressed during the frame
|
||||
}
|
||||
|
||||
/**
|
||||
* Update previous states - called at end of frame
|
||||
*/
|
||||
updatePreviousStates() {
|
||||
// Deep copy current states to previous for next frame
|
||||
this.keysPrevious = {};
|
||||
for (const key in this.keys) {
|
||||
this.keysPrevious[key] = this.keys[key];
|
||||
}
|
||||
this.mouse.buttonsPrevious = {};
|
||||
for (const button in this.mouse.buttons) {
|
||||
this.mouse.buttonsPrevious[button] = this.mouse.buttons[button];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is currently pressed
|
||||
*/
|
||||
isKeyPressed(key) {
|
||||
return this.keys[key.toLowerCase()] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key was just pressed (not held from previous frame)
|
||||
*/
|
||||
isKeyJustPressed(key) {
|
||||
const keyLower = key.toLowerCase();
|
||||
const isPressed = this.keys[keyLower] === true;
|
||||
const wasPressed = this.keysPrevious[keyLower] === true;
|
||||
return isPressed && !wasPressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mouse position
|
||||
*/
|
||||
getMousePosition() {
|
||||
return { x: this.mouse.x, y: this.mouse.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mouse button is pressed
|
||||
*/
|
||||
isMouseButtonPressed(button = 0) {
|
||||
return this.mouse.buttons[button] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mouse button was just pressed
|
||||
*/
|
||||
isMouseButtonJustPressed(button = 0) {
|
||||
const isPressed = this.mouse.buttons[button] === true;
|
||||
const wasPressed = this.mouse.buttonsPrevious[button] === true;
|
||||
return isPressed && !wasPressed;
|
||||
}
|
||||
}
|
||||
|
||||
110
src/systems/MenuSystem.js
Normal file
110
src/systems/MenuSystem.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { System } from '../core/System.js';
|
||||
|
||||
/**
|
||||
* System to handle game menus (start, pause)
|
||||
*/
|
||||
export class MenuSystem extends System {
|
||||
constructor(engine) {
|
||||
super('MenuSystem');
|
||||
this.requiredComponents = []; // No required components
|
||||
this.priority = 1; // Run early
|
||||
this.engine = engine;
|
||||
this.ctx = engine.ctx;
|
||||
this.gameState = 'start'; // 'start', 'playing', 'paused'
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
init(engine) {
|
||||
super.init(engine);
|
||||
this.setupInput();
|
||||
}
|
||||
|
||||
setupInput() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') {
|
||||
if (this.gameState === 'playing') {
|
||||
this.togglePause();
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (this.gameState === 'start') {
|
||||
this.startGame();
|
||||
} else if (this.gameState === 'paused') {
|
||||
this.resumeGame();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startGame() {
|
||||
this.gameState = 'playing';
|
||||
this.paused = false;
|
||||
if (!this.engine.running) {
|
||||
this.engine.start();
|
||||
}
|
||||
}
|
||||
|
||||
togglePause() {
|
||||
if (this.gameState === 'playing') {
|
||||
this.gameState = 'paused';
|
||||
this.paused = true;
|
||||
} else if (this.gameState === 'paused') {
|
||||
this.resumeGame();
|
||||
}
|
||||
}
|
||||
|
||||
resumeGame() {
|
||||
this.gameState = 'playing';
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
process(_deltaTime, _entities) {
|
||||
// Don't update game systems if paused or at start menu
|
||||
if (this.gameState === 'paused' || this.gameState === 'start') {
|
||||
// Pause all other systems
|
||||
this.engine.systems.forEach(system => {
|
||||
if (system !== this && system.name !== 'MenuSystem' && system.name !== 'UISystem') {
|
||||
// Systems will check game state themselves
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawMenu() {
|
||||
const ctx = this.ctx;
|
||||
const width = this.engine.canvas.width;
|
||||
const height = this.engine.canvas.height;
|
||||
|
||||
// Dark overlay
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 48px Courier New';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
if (this.gameState === 'start') {
|
||||
ctx.fillText('SLIME GENESIS', width / 2, height / 2 - 100);
|
||||
ctx.font = '24px Courier New';
|
||||
ctx.fillText('Press ENTER or SPACE to Start', width / 2, height / 2);
|
||||
ctx.font = '16px Courier New';
|
||||
ctx.fillText('WASD: Move | Mouse: Aim | Click/Space: Attack', width / 2, height / 2 + 50);
|
||||
ctx.fillText('Shift: Stealth | 1-9: Skills | ESC: Pause', width / 2, height / 2 + 80);
|
||||
} else if (this.gameState === 'paused') {
|
||||
ctx.fillText('PAUSED', width / 2, height / 2 - 50);
|
||||
ctx.font = '24px Courier New';
|
||||
ctx.fillText('Press ENTER or SPACE to Resume', width / 2, height / 2);
|
||||
ctx.fillText('Press ESC to Pause/Unpause', width / 2, height / 2 + 40);
|
||||
}
|
||||
}
|
||||
|
||||
getGameState() {
|
||||
return this.gameState;
|
||||
}
|
||||
|
||||
isPaused() {
|
||||
return this.paused || this.gameState === 'start';
|
||||
}
|
||||
}
|
||||
|
||||
62
src/systems/MovementSystem.js
Normal file
62
src/systems/MovementSystem.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { System } from '../core/System.js';
|
||||
|
||||
export class MovementSystem extends System {
|
||||
constructor() {
|
||||
super('MovementSystem');
|
||||
this.requiredComponents = ['Position', 'Velocity'];
|
||||
this.priority = 10;
|
||||
}
|
||||
|
||||
process(deltaTime, entities) {
|
||||
entities.forEach(entity => {
|
||||
const position = entity.getComponent('Position');
|
||||
const velocity = entity.getComponent('Velocity');
|
||||
const health = entity.getComponent('Health');
|
||||
|
||||
if (!position || !velocity) return;
|
||||
|
||||
// Check if this is a projectile
|
||||
const isProjectile = health && health.isProjectile;
|
||||
|
||||
// Apply velocity with max speed limit (skip for projectiles)
|
||||
if (!isProjectile) {
|
||||
const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy);
|
||||
if (speed > velocity.maxSpeed) {
|
||||
const factor = velocity.maxSpeed / speed;
|
||||
velocity.vx *= factor;
|
||||
velocity.vy *= factor;
|
||||
}
|
||||
}
|
||||
|
||||
// Update position
|
||||
position.x += velocity.vx * deltaTime;
|
||||
position.y += velocity.vy * deltaTime;
|
||||
|
||||
// Apply friction (skip for projectiles - they should maintain speed)
|
||||
if (!isProjectile) {
|
||||
const friction = 0.9;
|
||||
velocity.vx *= Math.pow(friction, deltaTime * 60);
|
||||
velocity.vy *= Math.pow(friction, deltaTime * 60);
|
||||
}
|
||||
|
||||
// Boundary checking
|
||||
const canvas = this.engine.canvas;
|
||||
if (position.x < 0) {
|
||||
position.x = 0;
|
||||
velocity.vx = 0;
|
||||
} else if (position.x > canvas.width) {
|
||||
position.x = canvas.width;
|
||||
velocity.vx = 0;
|
||||
}
|
||||
|
||||
if (position.y < 0) {
|
||||
position.y = 0;
|
||||
velocity.vy = 0;
|
||||
} else if (position.y > canvas.height) {
|
||||
position.y = canvas.height;
|
||||
velocity.vy = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
69
src/systems/PlayerControllerSystem.js
Normal file
69
src/systems/PlayerControllerSystem.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { System } from '../core/System.js';
|
||||
|
||||
export class PlayerControllerSystem extends System {
|
||||
constructor() {
|
||||
super('PlayerControllerSystem');
|
||||
this.requiredComponents = ['Position', 'Velocity'];
|
||||
this.priority = 5;
|
||||
this.playerEntity = null;
|
||||
}
|
||||
|
||||
process(deltaTime, entities) {
|
||||
// Find player entity (first entity with player tag or specific component)
|
||||
if (!this.playerEntity) {
|
||||
this.playerEntity = entities.find(e => e.hasComponent('Evolution'));
|
||||
}
|
||||
|
||||
if (!this.playerEntity) return;
|
||||
|
||||
const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem');
|
||||
if (!inputSystem) return;
|
||||
|
||||
const velocity = this.playerEntity.getComponent('Velocity');
|
||||
const position = this.playerEntity.getComponent('Position');
|
||||
if (!velocity || !position) return;
|
||||
|
||||
// Movement input
|
||||
let moveX = 0;
|
||||
let moveY = 0;
|
||||
const moveSpeed = 200;
|
||||
|
||||
if (inputSystem.isKeyPressed('w') || inputSystem.isKeyPressed('arrowup')) {
|
||||
moveY -= 1;
|
||||
}
|
||||
if (inputSystem.isKeyPressed('s') || inputSystem.isKeyPressed('arrowdown')) {
|
||||
moveY += 1;
|
||||
}
|
||||
if (inputSystem.isKeyPressed('a') || inputSystem.isKeyPressed('arrowleft')) {
|
||||
moveX -= 1;
|
||||
}
|
||||
if (inputSystem.isKeyPressed('d') || inputSystem.isKeyPressed('arrowright')) {
|
||||
moveX += 1;
|
||||
}
|
||||
|
||||
// Normalize diagonal movement
|
||||
if (moveX !== 0 && moveY !== 0) {
|
||||
moveX *= 0.707;
|
||||
moveY *= 0.707;
|
||||
}
|
||||
|
||||
// Apply movement
|
||||
velocity.vx = moveX * moveSpeed;
|
||||
velocity.vy = moveY * moveSpeed;
|
||||
|
||||
// Face mouse or movement direction
|
||||
const mouse = inputSystem.getMousePosition();
|
||||
const dx = mouse.x - position.x;
|
||||
const dy = mouse.y - position.y;
|
||||
if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) {
|
||||
position.rotation = Math.atan2(dy, dx);
|
||||
} else if (moveX !== 0 || moveY !== 0) {
|
||||
position.rotation = Math.atan2(moveY, moveX);
|
||||
}
|
||||
}
|
||||
|
||||
getPlayerEntity() {
|
||||
return this.playerEntity;
|
||||
}
|
||||
}
|
||||
|
||||
83
src/systems/ProjectileSystem.js
Normal file
83
src/systems/ProjectileSystem.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { System } from '../core/System.js';
|
||||
|
||||
export class ProjectileSystem extends System {
|
||||
constructor() {
|
||||
super('ProjectileSystem');
|
||||
this.requiredComponents = ['Position', 'Velocity'];
|
||||
this.priority = 18;
|
||||
}
|
||||
|
||||
process(deltaTime, entities) {
|
||||
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
|
||||
const _player = playerController ? playerController.getPlayerEntity() : null;
|
||||
|
||||
entities.forEach(entity => {
|
||||
const health = entity.getComponent('Health');
|
||||
if (!health || !health.isProjectile) return;
|
||||
|
||||
const position = entity.getComponent('Position');
|
||||
if (!position) return;
|
||||
|
||||
// Check range - remove if traveled beyond max range
|
||||
if (entity.startX !== undefined && entity.startY !== undefined && entity.maxRange !== undefined) {
|
||||
const dx = position.x - entity.startX;
|
||||
const dy = position.y - entity.startY;
|
||||
const distanceTraveled = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distanceTraveled >= entity.maxRange) {
|
||||
this.engine.removeEntity(entity);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check lifetime as backup
|
||||
if (entity.lifetime !== undefined) {
|
||||
entity.lifetime -= deltaTime;
|
||||
if (entity.lifetime <= 0) {
|
||||
this.engine.removeEntity(entity);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check collisions with enemies
|
||||
const allEntities = this.engine.getEntities();
|
||||
allEntities.forEach(target => {
|
||||
if (target.id === entity.owner) return;
|
||||
if (target.id === entity.id) return;
|
||||
if (!target.hasComponent('Health')) return;
|
||||
if (target.getComponent('Health').isProjectile) return;
|
||||
|
||||
const targetPos = target.getComponent('Position');
|
||||
if (!targetPos) return;
|
||||
|
||||
const dx = targetPos.x - position.x;
|
||||
const dy = targetPos.y - position.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 20) {
|
||||
// Hit!
|
||||
const targetHealth = target.getComponent('Health');
|
||||
const damage = entity.damage || 10;
|
||||
targetHealth.takeDamage(damage);
|
||||
|
||||
// If target is dead, mark it for immediate removal
|
||||
if (targetHealth.isDead()) {
|
||||
target.active = false;
|
||||
// DeathSystem will handle removal
|
||||
}
|
||||
|
||||
// Remove projectile
|
||||
this.engine.removeEntity(entity);
|
||||
}
|
||||
});
|
||||
|
||||
// Boundary check
|
||||
const canvas = this.engine.canvas;
|
||||
if (position.x < 0 || position.x > canvas.width ||
|
||||
position.y < 0 || position.y > canvas.height) {
|
||||
this.engine.removeEntity(entity);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
437
src/systems/RenderSystem.js
Normal file
437
src/systems/RenderSystem.js
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
import { System } from '../core/System.js';
|
||||
|
||||
export class RenderSystem extends System {
|
||||
constructor(engine) {
|
||||
super('RenderSystem');
|
||||
this.requiredComponents = ['Position', 'Sprite'];
|
||||
this.priority = 100; // Render last
|
||||
this.engine = engine;
|
||||
this.ctx = engine.ctx;
|
||||
}
|
||||
|
||||
process(deltaTime, _entities) {
|
||||
// Clear canvas
|
||||
this.engine.clear();
|
||||
|
||||
// Draw background
|
||||
this.drawBackground();
|
||||
|
||||
// Draw entities
|
||||
// Get all entities including inactive ones for rendering dead absorbable entities
|
||||
const allEntities = this.engine.entities;
|
||||
allEntities.forEach(entity => {
|
||||
const health = entity.getComponent('Health');
|
||||
const evolution = entity.getComponent('Evolution');
|
||||
|
||||
// Skip inactive entities UNLESS they're dead and absorbable (for absorption window)
|
||||
if (!entity.active) {
|
||||
const absorbable = entity.getComponent('Absorbable');
|
||||
if (health && health.isDead() && absorbable && !absorbable.absorbed) {
|
||||
// Render dead absorbable entities even if inactive (fade them out)
|
||||
this.drawEntity(entity, deltaTime, true); // Pass fade flag
|
||||
return;
|
||||
}
|
||||
return; // Skip other inactive entities
|
||||
}
|
||||
|
||||
// Don't render dead non-player entities (unless they're absorbable, handled above)
|
||||
if (health && health.isDead() && !evolution) {
|
||||
const absorbable = entity.getComponent('Absorbable');
|
||||
if (!absorbable || absorbable.absorbed) {
|
||||
return; // Skip dead non-absorbable entities
|
||||
}
|
||||
}
|
||||
|
||||
this.drawEntity(entity, deltaTime);
|
||||
});
|
||||
|
||||
// Draw skill effects
|
||||
this.drawSkillEffects();
|
||||
}
|
||||
|
||||
drawBackground() {
|
||||
const ctx = this.ctx;
|
||||
const width = this.engine.canvas.width;
|
||||
const height = this.engine.canvas.height;
|
||||
|
||||
// Cave background with gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, '#0f0f1f');
|
||||
gradient.addColorStop(1, '#1a1a2e');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Add cave features with better visuals
|
||||
ctx.fillStyle = '#2a2a3e';
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const x = (i * 70 + Math.sin(i) * 30) % width;
|
||||
const y = (i * 50 + Math.cos(i) * 40) % height;
|
||||
const size = 25 + (i % 4) * 15;
|
||||
|
||||
// Add shadow
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Add some ambient lighting
|
||||
const lightGradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, 400);
|
||||
lightGradient.addColorStop(0, 'rgba(100, 150, 200, 0.1)');
|
||||
lightGradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
ctx.fillStyle = lightGradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
drawEntity(entity, deltaTime, isDeadFade = false) {
|
||||
const position = entity.getComponent('Position');
|
||||
const sprite = entity.getComponent('Sprite');
|
||||
const health = entity.getComponent('Health');
|
||||
|
||||
if (!position || !sprite) return;
|
||||
|
||||
this.ctx.save();
|
||||
// Fade out dead entities
|
||||
let alpha = sprite.alpha;
|
||||
if (isDeadFade && health && health.isDead()) {
|
||||
const absorbable = entity.getComponent('Absorbable');
|
||||
if (absorbable && !absorbable.absorbed) {
|
||||
// Calculate fade based on time since death
|
||||
const deathTime = entity.deathTime || Date.now();
|
||||
const timeSinceDeath = (Date.now() - deathTime) / 1000;
|
||||
const fadeTime = 3.0; // 3 seconds to fade (matches DeathSystem removal time)
|
||||
alpha = Math.max(0.3, 1.0 - (timeSinceDeath / fadeTime));
|
||||
}
|
||||
}
|
||||
this.ctx.globalAlpha = alpha;
|
||||
this.ctx.translate(position.x, position.y);
|
||||
this.ctx.rotate(position.rotation);
|
||||
this.ctx.scale(sprite.scale, sprite.scale);
|
||||
|
||||
// Update animation time for slime morphing
|
||||
if (sprite.shape === 'slime') {
|
||||
sprite.animationTime += deltaTime;
|
||||
sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8;
|
||||
}
|
||||
|
||||
// Draw based on shape
|
||||
this.ctx.fillStyle = sprite.color;
|
||||
|
||||
if (sprite.shape === 'circle' || sprite.shape === 'slime') {
|
||||
this.drawSlime(sprite);
|
||||
} else if (sprite.shape === 'rect') {
|
||||
this.ctx.fillRect(-sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height);
|
||||
}
|
||||
|
||||
// Draw health bar if entity has health
|
||||
if (health && health.maxHp > 0) {
|
||||
this.drawHealthBar(health, sprite);
|
||||
}
|
||||
|
||||
// Draw combat indicator if attacking
|
||||
const combat = entity.getComponent('Combat');
|
||||
if (combat && combat.isAttacking) {
|
||||
// Draw attack indicator relative to entity's current rotation
|
||||
// Since we're already rotated, we need to draw relative to 0,0 forward
|
||||
this.drawAttackIndicator(combat, position);
|
||||
}
|
||||
|
||||
// Draw stealth indicator
|
||||
const stealth = entity.getComponent('Stealth');
|
||||
if (stealth && stealth.isStealthed) {
|
||||
this.drawStealthIndicator(stealth, sprite);
|
||||
}
|
||||
|
||||
// Mutation Visual Effects
|
||||
const evolution = entity.getComponent('Evolution');
|
||||
if (evolution) {
|
||||
if (evolution.mutationEffects.glowingBody) {
|
||||
// Draw light aura
|
||||
const auraGradient = this.ctx.createRadialGradient(0, 0, 0, 0, 0, sprite.width * 2);
|
||||
auraGradient.addColorStop(0, 'rgba(255, 255, 200, 0.2)');
|
||||
auraGradient.addColorStop(1, 'rgba(255, 255, 200, 0)');
|
||||
this.ctx.fillStyle = auraGradient;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(0, 0, sprite.width * 2, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
}
|
||||
if (evolution.mutationEffects.electricSkin) {
|
||||
// Add tiny sparks
|
||||
if (Math.random() < 0.2) {
|
||||
this.ctx.strokeStyle = '#00ffff';
|
||||
this.ctx.lineWidth = 2;
|
||||
this.ctx.beginPath();
|
||||
const sparkX = (Math.random() - 0.5) * sprite.width;
|
||||
const sparkY = (Math.random() - 0.5) * sprite.height;
|
||||
this.ctx.moveTo(sparkX, sparkY);
|
||||
this.ctx.lineTo(sparkX + (Math.random() - 0.5) * 10, sparkY + (Math.random() - 0.5) * 10);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
if (evolution.mutationEffects.hardenedShell) {
|
||||
// Darker, thicker border
|
||||
this.ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
this.ctx.lineWidth = 3;
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
drawSlime(sprite) {
|
||||
const ctx = this.ctx;
|
||||
const baseRadius = Math.min(sprite.width, sprite.height) / 2;
|
||||
|
||||
if (sprite.shape === 'slime') {
|
||||
// Animated slime blob with morphing and better visuals
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = sprite.color;
|
||||
|
||||
// Main body with morphing
|
||||
ctx.beginPath();
|
||||
const points = 16;
|
||||
for (let i = 0; i < points; i++) {
|
||||
const angle = (i / points) * Math.PI * 2;
|
||||
const morph1 = Math.sin(angle * 2 + sprite.animationTime * 2) * 0.15;
|
||||
const morph2 = Math.cos(angle * 3 + sprite.animationTime * 1.5) * 0.1;
|
||||
const radius = baseRadius * (sprite.morphAmount + morph1 + morph2);
|
||||
const x = Math.cos(angle) * radius;
|
||||
const y = Math.sin(angle) * radius;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Inner glow
|
||||
const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.8);
|
||||
innerGradient.addColorStop(0, 'rgba(255, 255, 255, 0.4)');
|
||||
innerGradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
|
||||
ctx.fillStyle = innerGradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, baseRadius * 0.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Highlight
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(-baseRadius * 0.3, -baseRadius * 0.3, baseRadius * 0.35, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = sprite.color;
|
||||
ctx.shadowBlur = 0;
|
||||
} else {
|
||||
// Simple circle with glow
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = sprite.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, baseRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
drawHealthBar(health, sprite) {
|
||||
const ctx = this.ctx;
|
||||
const barWidth = sprite.width * 1.5;
|
||||
const barHeight = 4;
|
||||
const yOffset = sprite.height / 2 + 10;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.fillRect(-barWidth / 2, -yOffset, barWidth, barHeight);
|
||||
|
||||
// Health fill
|
||||
const healthPercent = health.hp / health.maxHp;
|
||||
ctx.fillStyle = healthPercent > 0.5 ? '#00ff00' : healthPercent > 0.25 ? '#ffff00' : '#ff0000';
|
||||
ctx.fillRect(-barWidth / 2, -yOffset, barWidth * healthPercent, barHeight);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(-barWidth / 2, -yOffset, barWidth, barHeight);
|
||||
}
|
||||
|
||||
drawAttackIndicator(combat, _position) {
|
||||
const ctx = this.ctx;
|
||||
const length = 50;
|
||||
const attackProgress = 1.0 - (combat.attackCooldown / 0.3); // 0 to 1 during attack animation
|
||||
|
||||
// Since we're already in entity's rotated coordinate space (ctx.rotate was applied),
|
||||
// and position.rotation should match combat.attackDirection (set in CombatSystem),
|
||||
// we just draw forward (angle 0) in local space
|
||||
const angle = 0; // Forward in local rotated space
|
||||
|
||||
// Draw slime tentacle/extension
|
||||
ctx.strokeStyle = `rgba(0, 255, 150, ${0.8 * attackProgress})`;
|
||||
ctx.fillStyle = `rgba(0, 255, 150, ${0.6 * attackProgress})`;
|
||||
ctx.lineWidth = 8;
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
// Tentacle extends outward during attack (forward from entity)
|
||||
const tentacleLength = length * attackProgress;
|
||||
const tentacleEndX = Math.cos(angle) * tentacleLength;
|
||||
const tentacleEndY = Math.sin(angle) * tentacleLength;
|
||||
|
||||
// Draw curved tentacle
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
// Add slight curve to tentacle
|
||||
const midX = Math.cos(angle) * tentacleLength * 0.5;
|
||||
const midY = Math.sin(angle) * tentacleLength * 0.5;
|
||||
const perpX = -Math.sin(angle) * 5 * attackProgress;
|
||||
const perpY = Math.cos(angle) * 5 * attackProgress;
|
||||
ctx.quadraticCurveTo(midX + perpX, midY + perpY, tentacleEndX, tentacleEndY);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw impact point
|
||||
if (attackProgress > 0.5) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(tentacleEndX, tentacleEndY, 6 * attackProgress, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
drawStealthIndicator(stealth, sprite) {
|
||||
const ctx = this.ctx;
|
||||
const radius = Math.max(sprite.width, sprite.height) / 2 + 5;
|
||||
|
||||
// Stealth ring
|
||||
ctx.strokeStyle = `rgba(0, 255, 150, ${1 - stealth.visibility})`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Visibility indicator
|
||||
if (stealth.visibility > 0.3) {
|
||||
ctx.fillStyle = `rgba(255, 0, 0, ${(stealth.visibility - 0.3) * 2})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, -radius - 10, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
drawSkillEffects() {
|
||||
const skillEffectSystem = this.engine.systems.find(s => s.name === 'SkillEffectSystem');
|
||||
if (!skillEffectSystem) return;
|
||||
|
||||
const effects = skillEffectSystem.getEffects();
|
||||
const ctx = this.ctx;
|
||||
|
||||
effects.forEach(effect => {
|
||||
ctx.save();
|
||||
|
||||
switch (effect.type) {
|
||||
case 'fire_breath':
|
||||
this.drawFireBreath(ctx, effect);
|
||||
break;
|
||||
case 'pounce':
|
||||
this.drawPounce(ctx, effect);
|
||||
break;
|
||||
case 'pounce_impact':
|
||||
this.drawPounceImpact(ctx, effect);
|
||||
break;
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
drawFireBreath(ctx, effect) {
|
||||
const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1
|
||||
const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative
|
||||
|
||||
// Draw fire cone
|
||||
ctx.translate(effect.x, effect.y);
|
||||
ctx.rotate(effect.angle);
|
||||
|
||||
// Cone gradient
|
||||
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range);
|
||||
gradient.addColorStop(0, `rgba(255, 100, 0, ${alpha * 0.8})`);
|
||||
gradient.addColorStop(0.5, `rgba(255, 200, 0, ${alpha * 0.6})`);
|
||||
gradient.addColorStop(1, `rgba(255, 50, 0, ${alpha * 0.3})`);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.arc(0, 0, effect.range, -effect.coneAngle / 2, effect.coneAngle / 2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Fire particles
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const angle = (Math.random() - 0.5) * effect.coneAngle;
|
||||
const dist = Math.random() * effect.range * progress;
|
||||
const x = Math.cos(angle) * dist;
|
||||
const y = Math.sin(angle) * dist;
|
||||
const size = 3 + Math.random() * 5;
|
||||
|
||||
ctx.fillStyle = `rgba(255, ${150 + Math.random() * 100}, 0, ${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
drawPounce(ctx, effect) {
|
||||
const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1
|
||||
const currentX = effect.startX + Math.cos(effect.angle) * effect.speed * effect.time;
|
||||
const currentY = effect.startY + Math.sin(effect.angle) * effect.speed * effect.time;
|
||||
|
||||
// Draw dash trail
|
||||
const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative
|
||||
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(effect.startX, effect.startY);
|
||||
ctx.lineTo(currentX, currentY);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw impact point
|
||||
const radius = Math.max(0, 15 * (1 - progress)); // Ensure non-negative radius
|
||||
if (radius > 0) {
|
||||
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(currentX, currentY, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
drawPounceImpact(ctx, effect) {
|
||||
const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1
|
||||
const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative
|
||||
const size = Math.max(0, 30 * (1 - progress)); // Ensure non-negative size
|
||||
|
||||
if (size > 0 && alpha > 0) {
|
||||
// Impact ring
|
||||
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(effect.x, effect.y, size, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Impact particles
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = (i / 8) * Math.PI * 2;
|
||||
const dist = size * 0.7;
|
||||
const x = effect.x + Math.cos(angle) * dist;
|
||||
const y = effect.y + Math.sin(angle) * dist;
|
||||
|
||||
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
src/systems/SkillEffectSystem.js
Normal file
41
src/systems/SkillEffectSystem.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { System } from '../core/System.js';
|
||||
|
||||
/**
|
||||
* System to track and render skill effects (Fire Breath, Pounce, etc.)
|
||||
*/
|
||||
export class SkillEffectSystem extends System {
|
||||
constructor() {
|
||||
super('SkillEffectSystem');
|
||||
this.requiredComponents = []; // No required components
|
||||
this.priority = 50; // Run after skills but before rendering
|
||||
this.activeEffects = [];
|
||||
}
|
||||
|
||||
process(deltaTime, _entities) {
|
||||
// Update all active effects
|
||||
for (let i = this.activeEffects.length - 1; i >= 0; i--) {
|
||||
const effect = this.activeEffects[i];
|
||||
effect.lifetime -= deltaTime;
|
||||
effect.time += deltaTime;
|
||||
|
||||
if (effect.lifetime <= 0) {
|
||||
this.activeEffects.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a skill effect
|
||||
*/
|
||||
addEffect(effect) {
|
||||
this.activeEffects.push(effect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active effects
|
||||
*/
|
||||
getEffects() {
|
||||
return this.activeEffects;
|
||||
}
|
||||
}
|
||||
|
||||
53
src/systems/SkillSystem.js
Normal file
53
src/systems/SkillSystem.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { System } from '../core/System.js';
|
||||
import { SkillRegistry } from '../skills/SkillRegistry.js';
|
||||
|
||||
export class SkillSystem extends System {
|
||||
constructor() {
|
||||
super('SkillSystem');
|
||||
this.requiredComponents = ['Skills'];
|
||||
this.priority = 30;
|
||||
}
|
||||
|
||||
process(deltaTime, entities) {
|
||||
const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem');
|
||||
if (!inputSystem) return;
|
||||
|
||||
entities.forEach(entity => {
|
||||
const skills = entity.getComponent('Skills');
|
||||
if (!skills) return;
|
||||
|
||||
// Update cooldowns
|
||||
skills.updateCooldowns(deltaTime);
|
||||
|
||||
// Check for skill activation (number keys 1-9)
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const key = i.toString();
|
||||
if (inputSystem.isKeyJustPressed(key)) {
|
||||
const skillIndex = i - 1;
|
||||
if (skillIndex < skills.activeSkills.length) {
|
||||
const skillId = skills.activeSkills[skillIndex];
|
||||
if (!skills.isOnCooldown(skillId)) {
|
||||
this.activateSkill(entity, skillId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
activateSkill(entity, skillId) {
|
||||
const skill = SkillRegistry.get(skillId);
|
||||
if (!skill) {
|
||||
console.warn(`Skill not found: ${skillId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (skill.activate(entity, this.engine)) {
|
||||
const skills = entity.getComponent('Skills');
|
||||
if (skills) {
|
||||
skills.setCooldown(skillId, skill.cooldown);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
src/systems/StealthSystem.js
Normal file
74
src/systems/StealthSystem.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { System } from '../core/System.js';
|
||||
|
||||
export class StealthSystem extends System {
|
||||
constructor() {
|
||||
super('StealthSystem');
|
||||
this.requiredComponents = ['Stealth'];
|
||||
this.priority = 12;
|
||||
}
|
||||
|
||||
process(deltaTime, entities) {
|
||||
const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem');
|
||||
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
|
||||
const player = playerController ? playerController.getPlayerEntity() : null;
|
||||
|
||||
entities.forEach(entity => {
|
||||
const stealth = entity.getComponent('Stealth');
|
||||
const velocity = entity.getComponent('Velocity');
|
||||
const combat = entity.getComponent('Combat');
|
||||
const evolution = entity.getComponent('Evolution');
|
||||
|
||||
if (!stealth) return;
|
||||
|
||||
// Determine stealth type based on evolution
|
||||
if (evolution) {
|
||||
const form = evolution.getDominantForm();
|
||||
stealth.stealthType = form;
|
||||
}
|
||||
|
||||
// Check if player wants to toggle stealth
|
||||
if (entity === player && inputSystem) {
|
||||
const shiftPress = inputSystem.isKeyJustPressed('shift');
|
||||
if (shiftPress) {
|
||||
if (stealth.isStealthed) {
|
||||
stealth.exitStealth();
|
||||
} else {
|
||||
stealth.enterStealth(stealth.stealthType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update stealth based on movement and combat
|
||||
const isMoving = velocity && (Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1);
|
||||
const isInCombat = combat && combat.isAttacking;
|
||||
|
||||
stealth.updateStealth(isMoving, isInCombat);
|
||||
|
||||
// Form-specific stealth bonuses
|
||||
if (stealth.isStealthed) {
|
||||
switch (stealth.stealthType) {
|
||||
case 'slime':
|
||||
// Slime can be very hidden when not moving
|
||||
if (!isMoving) {
|
||||
stealth.visibility = Math.max(0.05, stealth.visibility - deltaTime * 0.2);
|
||||
}
|
||||
break;
|
||||
case 'beast':
|
||||
// Beast stealth is better when moving slowly
|
||||
if (isMoving && velocity) {
|
||||
const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy);
|
||||
if (speed < 50) {
|
||||
stealth.visibility = Math.max(0.1, stealth.visibility - deltaTime * 0.1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'human':
|
||||
// Human stealth is more consistent
|
||||
stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
311
src/systems/UISystem.js
Normal file
311
src/systems/UISystem.js
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { System } from '../core/System.js';
|
||||
import { SkillRegistry } from '../skills/SkillRegistry.js';
|
||||
import { Events } from '../core/EventBus.js';
|
||||
|
||||
export class UISystem extends System {
|
||||
constructor(engine) {
|
||||
super('UISystem');
|
||||
this.requiredComponents = []; // No required components - renders UI
|
||||
this.priority = 200; // Render after everything else
|
||||
this.engine = engine;
|
||||
this.ctx = engine.ctx;
|
||||
|
||||
this.damageNumbers = [];
|
||||
this.notifications = [];
|
||||
|
||||
// Subscribe to events
|
||||
engine.on(Events.DAMAGE_DEALT, (data) => this.addDamageNumber(data));
|
||||
engine.on(Events.MUTATION_GAINED, (data) => this.addNotification(`Mutation Gained: ${data.name}`));
|
||||
}
|
||||
|
||||
addDamageNumber(data) {
|
||||
this.damageNumbers.push({
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
value: Math.floor(data.value),
|
||||
color: data.color || '#ffffff',
|
||||
lifetime: 1.0,
|
||||
vy: -50
|
||||
});
|
||||
}
|
||||
|
||||
addNotification(text) {
|
||||
this.notifications.push({
|
||||
text,
|
||||
lifetime: 3.0,
|
||||
alpha: 1.0
|
||||
});
|
||||
}
|
||||
|
||||
process(deltaTime, _entities) {
|
||||
// Update damage numbers
|
||||
this.updateDamageNumbers(deltaTime);
|
||||
this.updateNotifications(deltaTime);
|
||||
|
||||
const menuSystem = this.engine.systems.find(s => s.name === 'MenuSystem');
|
||||
const gameState = menuSystem ? menuSystem.getGameState() : 'playing';
|
||||
|
||||
// Only draw menu overlay if in start or paused state
|
||||
if (gameState === 'start' || gameState === 'paused') {
|
||||
if (menuSystem) {
|
||||
menuSystem.drawMenu();
|
||||
}
|
||||
// Don't draw game UI when menu is showing
|
||||
return;
|
||||
}
|
||||
|
||||
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
|
||||
const player = playerController ? playerController.getPlayerEntity() : null;
|
||||
|
||||
if (!player) return;
|
||||
|
||||
// Draw UI
|
||||
this.drawHUD(player);
|
||||
this.drawSkills(player);
|
||||
this.drawStats(player);
|
||||
this.drawSkillProgress(player);
|
||||
this.drawDamageNumbers();
|
||||
this.drawNotifications();
|
||||
this.drawAbsorptionEffects();
|
||||
}
|
||||
|
||||
drawHUD(player) {
|
||||
const health = player.getComponent('Health');
|
||||
const stats = player.getComponent('Stats');
|
||||
const evolution = player.getComponent('Evolution');
|
||||
const skills = player.getComponent('Skills');
|
||||
|
||||
if (!health || !stats || !evolution) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
const _width = this.engine.canvas.width;
|
||||
const _height = this.engine.canvas.height;
|
||||
|
||||
// Health bar
|
||||
const barWidth = 200;
|
||||
const barHeight = 20;
|
||||
const barX = 20;
|
||||
const barY = 20;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.fillRect(barX, barY, barWidth, barHeight);
|
||||
|
||||
const healthPercent = health.hp / health.maxHp;
|
||||
ctx.fillStyle = healthPercent > 0.5 ? '#00ff00' : healthPercent > 0.25 ? '#ffff00' : '#ff0000';
|
||||
ctx.fillRect(barX, barY, barWidth * healthPercent, barHeight);
|
||||
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(barX, barY, barWidth, barHeight);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '14px Courier New';
|
||||
ctx.fillText(`HP: ${Math.ceil(health.hp)}/${health.maxHp}`, barX + 5, barY + 15);
|
||||
|
||||
// Evolution display
|
||||
const form = evolution.getDominantForm();
|
||||
const formY = barY + barHeight + 10;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '12px Courier New';
|
||||
ctx.fillText(`Form: ${form.toUpperCase()}`, barX, formY);
|
||||
ctx.fillText(`Human: ${evolution.human.toFixed(1)} | Beast: ${evolution.beast.toFixed(1)} | Slime: ${evolution.slime.toFixed(1)}`, barX, formY + 15);
|
||||
|
||||
// Instructions
|
||||
const instructionsY = formY + 40;
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.font = '11px Courier New';
|
||||
ctx.fillText('WASD: Move | Mouse: Aim | Click/Space: Attack', barX, instructionsY);
|
||||
ctx.fillText('Shift: Stealth | 1-9: Skills (Press 1 for Slime Gun)', barX, instructionsY + 15);
|
||||
|
||||
// Show skill hint if player has skills
|
||||
if (skills && skills.activeSkills.length > 0) {
|
||||
ctx.fillStyle = '#00ff96';
|
||||
ctx.fillText(`You have ${skills.activeSkills.length} skill(s)! Press 1-${skills.activeSkills.length} to use them.`, barX, instructionsY + 30);
|
||||
} else {
|
||||
ctx.fillStyle = '#ffaa00';
|
||||
ctx.fillText('Defeat and absorb creatures 5 times to learn their skills!', barX, instructionsY + 30);
|
||||
}
|
||||
|
||||
// Health regeneration hint
|
||||
ctx.fillStyle = '#00aaff';
|
||||
ctx.fillText('Health regenerates when not in combat', barX, instructionsY + 45);
|
||||
}
|
||||
|
||||
drawSkills(player) {
|
||||
const skills = player.getComponent('Skills');
|
||||
if (!skills) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
const width = this.engine.canvas.width;
|
||||
const startX = width - 250;
|
||||
const startY = 20;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.fillRect(startX, startY, 230, 30 + skills.activeSkills.length * 30);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '14px Courier New';
|
||||
ctx.fillText('Skills:', startX + 10, startY + 20);
|
||||
|
||||
skills.activeSkills.forEach((skillId, index) => {
|
||||
const y = startY + 40 + index * 30;
|
||||
const key = (index + 1).toString();
|
||||
const onCooldown = skills.isOnCooldown(skillId);
|
||||
const cooldown = skills.getCooldown(skillId);
|
||||
|
||||
// Get skill name from registry for display
|
||||
const skill = SkillRegistry.get(skillId);
|
||||
const skillName = skill ? skill.name : skillId.replace('_', ' ');
|
||||
|
||||
ctx.fillStyle = onCooldown ? '#888888' : '#00ff96';
|
||||
ctx.fillText(`${key}. ${skillName}${onCooldown ? ` (${cooldown.toFixed(1)}s)` : ''}`, startX + 10, y);
|
||||
});
|
||||
}
|
||||
|
||||
drawStats(player) {
|
||||
const stats = player.getComponent('Stats');
|
||||
if (!stats) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
const width = this.engine.canvas.width;
|
||||
const startX = width - 250;
|
||||
const startY = 200;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.fillRect(startX, startY, 230, 150);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '12px Courier New';
|
||||
let y = startY + 20;
|
||||
ctx.fillText('Stats:', startX + 10, y);
|
||||
y += 20;
|
||||
ctx.fillText(`STR: ${stats.strength}`, startX + 10, y);
|
||||
y += 15;
|
||||
ctx.fillText(`AGI: ${stats.agility}`, startX + 10, y);
|
||||
y += 15;
|
||||
ctx.fillText(`INT: ${stats.intelligence}`, startX + 10, y);
|
||||
y += 15;
|
||||
ctx.fillText(`CON: ${stats.constitution}`, startX + 10, y);
|
||||
y += 15;
|
||||
ctx.fillText(`PER: ${stats.perception}`, startX + 10, y);
|
||||
}
|
||||
|
||||
drawSkillProgress(player) {
|
||||
const skillProgress = player.getComponent('SkillProgress');
|
||||
if (!skillProgress) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
const width = this.engine.canvas.width;
|
||||
const startX = width - 250;
|
||||
const startY = 360;
|
||||
|
||||
const progress = skillProgress.getAllProgress();
|
||||
if (progress.size === 0) return;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.fillRect(startX, startY, 230, 30 + progress.size * 25);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '12px Courier New';
|
||||
ctx.fillText('Skill Progress:', startX + 10, startY + 20);
|
||||
|
||||
let y = startY + 35;
|
||||
progress.forEach((count, skillId) => {
|
||||
const required = skillProgress.requiredAbsorptions;
|
||||
const _percent = Math.min(100, (count / required) * 100);
|
||||
const skill = SkillRegistry.get(skillId);
|
||||
const skillName = skill ? skill.name : skillId.replace('_', ' ');
|
||||
|
||||
ctx.fillStyle = count >= required ? '#00ff00' : '#ffff00';
|
||||
ctx.fillText(`${skillName}: ${count}/${required}`, startX + 10, y);
|
||||
y += 20;
|
||||
});
|
||||
}
|
||||
|
||||
updateDamageNumbers(deltaTime) {
|
||||
for (let i = this.damageNumbers.length - 1; i >= 0; i--) {
|
||||
const num = this.damageNumbers[i];
|
||||
num.lifetime -= deltaTime;
|
||||
num.y += num.vy * deltaTime;
|
||||
num.vy *= 0.95;
|
||||
if (num.lifetime <= 0) this.damageNumbers.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
updateNotifications(deltaTime) {
|
||||
for (let i = this.notifications.length - 1; i >= 0; i--) {
|
||||
const note = this.notifications[i];
|
||||
note.lifetime -= deltaTime;
|
||||
if (note.lifetime < 0.5) note.alpha = note.lifetime * 2;
|
||||
if (note.lifetime <= 0) this.notifications.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
drawDamageNumbers() {
|
||||
const ctx = this.ctx;
|
||||
this.damageNumbers.forEach(num => {
|
||||
const alpha = Math.min(1, num.lifetime);
|
||||
const size = 14 + Math.min(num.value / 2, 10);
|
||||
|
||||
ctx.font = `bold ${size}px Courier New`;
|
||||
// Shadow
|
||||
ctx.fillStyle = `rgba(0, 0, 0, ${alpha * 0.5})`;
|
||||
ctx.fillText(num.value.toString(), num.x + 2, num.y + 2);
|
||||
|
||||
// Main text
|
||||
ctx.fillStyle = num.color.startsWith('rgba') ? num.color : `rgba(${this.hexToRgb(num.color)}, ${alpha})`;
|
||||
ctx.fillText(num.value.toString(), num.x, num.y);
|
||||
});
|
||||
}
|
||||
|
||||
hexToRgb(hex) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
drawNotifications() {
|
||||
const ctx = this.ctx;
|
||||
const width = this.engine.canvas.width;
|
||||
|
||||
this.notifications.forEach((note, index) => {
|
||||
ctx.fillStyle = `rgba(255, 255, 0, ${note.alpha})`;
|
||||
ctx.font = 'bold 20px Courier New';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(note.text, width / 2, 100 + index * 30);
|
||||
ctx.textAlign = 'left';
|
||||
});
|
||||
}
|
||||
|
||||
drawAbsorptionEffects() {
|
||||
const absorptionSystem = this.engine.systems.find(s => s.name === 'AbsorptionSystem');
|
||||
if (!absorptionSystem || !absorptionSystem.getEffects) return;
|
||||
|
||||
const effects = absorptionSystem.getEffects();
|
||||
const ctx = this.ctx;
|
||||
|
||||
effects.forEach(effect => {
|
||||
const alpha = Math.min(1, effect.lifetime * 2);
|
||||
|
||||
// Glow effect
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = effect.color;
|
||||
ctx.fillStyle = effect.color;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.beginPath();
|
||||
ctx.arc(effect.x, effect.y, effect.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Inner bright core
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(effect.x, effect.y, effect.size * 0.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.shadowBlur = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
27
src/world/World.js
Normal file
27
src/world/World.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* World manager - handles areas and world state
|
||||
*/
|
||||
export class World {
|
||||
constructor() {
|
||||
this.areas = [];
|
||||
this.currentArea = null;
|
||||
this.areas.push({
|
||||
id: 'cave',
|
||||
name: 'Dark Cave',
|
||||
type: 'cave',
|
||||
spawnTypes: ['humanoid', 'beast', 'elemental'],
|
||||
spawnRate: 0.5
|
||||
});
|
||||
|
||||
this.currentArea = this.areas[0];
|
||||
}
|
||||
|
||||
getCurrentArea() {
|
||||
return this.currentArea;
|
||||
}
|
||||
|
||||
getSpawnTypes() {
|
||||
return this.currentArea ? this.currentArea.spawnTypes : ['beast'];
|
||||
}
|
||||
}
|
||||
|
||||
25
vite.config.js
Normal file
25
vite.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
},
|
||||
mangle: {
|
||||
toplevel: true,
|
||||
},
|
||||
format: {
|
||||
comments: false,
|
||||
},
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: undefined,
|
||||
},
|
||||
},
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue