Software Developer
Searching for internship
Hi, I’m Collin, a software development student at ROC van Twente in the Netherlands. I love building things, whether that’s software (Games, code-automation, etc), 3D models, or shaders. I’m currently looking for an internship where I can grow or learn new skills.
Languages
c#
Advanced
Javascript
Moderate
HTML
Moderate
CSS
Average
Software
Unity
Advanced
Blender
Advanced
Substance painter
Advanced
Inkscape
Average
Work Experiences
TotalReality
Period
3/02/25 - 20/06/25
Zoete kruimels
Period
6/02/23 - 21/04/23
Nienke's cupcakes
Period
15/11/21 - 4/02/22
TempTitle
Education
ROC van twente
Period
01/08/23 - 31/07/27
ROC van twente
Period
01/08/19 - 31/07/23
TempTitle
Projects
All
Unity
Blender
Shaders
The Filth
90's desktop bullet hell.
Read more
Cyberpunk gun
model made for fun
Read more
Towaria
A voxel based tower defense.
Read more
SDF shader
Shader made using glsl
Read more
Tiny house
A small cozy house
Read more
Ray Marching
Shader made using glsl
Read more
Howling moon
Dark toon card game
Read more
Geiger//Counter
Card-based combat
Read more
The Filth
Random Project
Projects
about
The Filth is a space shooter-like game made during a gamejam with the theme: "Making free space", where you have to shoot at files to destroy them, and survive their attacks. As the player you can shoot projectiles to destroy enemies, and also have access to a special attack that hurts multiple enemies.
contribution
In this project my role was SCRUM master and lead developer. I focused on integrating game systems that the other group members made, but also made my own systems like the spawning grid. I also made the UI of the game.
script.cs
Controls the "eye" in the center of the screen. It either looks at the player or a random points on screen, switching targets at random intervals.
/// Controls the "eye" in the center of the screen.
/// it either looks at the player or a random points on screen,
/// switching targets at random intervals.
using System.Collections;
using UnityEngine;
public class TheFilthLook : MonoBehaviour
{
[SerializeField]
private GameObject eye = null;
[SerializeField]
private float minDistanceFromPlayer = 0.5f;
[SerializeField]
private Vector2 TargetSwitchDelay = Vector2.zero;
[SerializeField]
private float lookSpeed = 5f;
private Transform playerT = null;
private float minX, maxX, minY, maxY = 0;
private bool isFollowingPlayer = false;
private Vector2 target = Vector2.zero;
void Start()
{
playerT = GameObject.FindGameObjectWithTag("Player").transform;
//ensures the eye chooses a point that's actually on the screen.
Vector2 bottomLeft = Camera.main.ViewportToWorldPoint(Vector2.zero);
Vector2 topRight = Camera.main.ViewportToWorldPoint(Vector2.one);
minX = bottomLeft.x;
minY = bottomLeft.y;
maxX = topRight.x;
maxY = topRight.y;
target = transform.position;
StartCoroutine(ChooseTarget());
}
void Update()
{
/// calculates the point where the eye needs to move to,
/// limited to a radius from the root object.
Vector2 dir = target - (Vector2)transform.position;
float distance = dir.magnitude;
if (distance > 0f)
{
dir /= distance;
}
dir *= minDistanceFromPlayer;
if (isFollowingPlayer && distance > minDistanceFromPlayer)
{
target = playerT.position;
}
// actually move the eye, smoothly
eye.transform.position = Vector2.Lerp
(
eye.transform.position,
(Vector2)transform.position + dir,
Time.deltaTime * lookSpeed
);
}
IEnumerator ChooseTarget()
{
// delay to avoid it moving at the start.
yield return new WaitForSecondsRealtime(10f);
// not save, but okay for a gamejam game.
while (true)
{
float minDelay = TargetSwitchDelay.x;
float maxDelay = TargetSwitchDelay.y;
if (isFollowingPlayer)
{
minDelay += 5;
maxDelay += 5;
}
float randomDelay = Random.Range(minDelay, maxDelay);
yield return new WaitForSecondsRealtime(randomDelay);
isFollowingPlayer = Random.value < 0.5f;
if (!isFollowingPlayer)
{
target = ChooseRandomLookTarget();
}
}
}
Vector2 ChooseRandomLookTarget()
{
Vector2 target = Vector2.zero;
target.x = Random.Range(minX, maxX);
target.y = Random.Range(minY, maxY);
return target;
}
}
Cyberpunk gun
Random Project
Projects
about
I made this model as a side project for myself, I slowly worked on it over the course of around 2 months i spend a bit of time on it every so often. I used no reference images to make this model as a challenge for myself.
Towaria
Random Project
Projects
about
Towaria is a challenging tower defense game where letting enemies reach the end means defeat. Place auto-firing towers to eliminate all enemies and advance through waves. Survive the final wave to move on to the next level.
contribution
I focused on core game mechanics, like tower placement and stats. I also contributed to 3D models (ground tiles, a tower, enemies) and helped polish the UI to ensure visual consistency after functionality was implemented.
script.cs
This script manages the movement of every enemy. Add an enemy to the list if you want it to be controlled by this script, remove it when you want it to stop being controlled by this script.
/// this script manages the movement of every enemy,
/// add an enemy to the list if you want it to be controlled by this script,
/// remove it when you want it to stop being controlled by this script.
using UnityEngine;
using Unity.Jobs;
using Unity.Burst;
using Unity.Collections;
using UnityEngine.Jobs;
using System.Collections.Generic;
public class EnemyMovementSystem : MonoBehaviour
{
//enemies hold all enemies, enemiesToUpdate is a list of all enemies used in the current job,
//to avoid shifting list issues
private readonly List<EnemyBase> enemies = new();
private List<EnemyBase> enemiesToUpdate = new();
//store some values to avoid having to call pathcreator more then needed
public Vector3 PathStart { get; private set; }
private int pathReachedEndIndex;
// data to pass on to job, native arrays are required when using jobs (multithreading)
private TransformAccessArray enemyTransforms;
private NativeArray<Vector3> pathNodes;
private NativeArray<float> speeds;
private NativeArray<Vector3> offsets;
private NativeArray<int> targetNodeIndices;
private CreateAIPath pathCreator;
// stores a reference to the job itself
private JobHandle jobHandle;
private bool jobAlreadyCompleted = false;
// allow enemy to add and remove itself to the movement job
public void AddEnemy(EnemyBase enemy) => enemies.Add(enemy);
public void RemoveEnemy(EnemyBase enemy) => enemies.Remove(enemy);
private void Start()
{
pathCreator = FindFirstObjectByType<CreateAIPath>();
pathCreator.RegeneratedPaths += InitializePathNodes;
}
public void InitializePathNodes()
{
PathStart = pathCreator.Path[0];
pathReachedEndIndex = pathCreator.Path.Count;
// Displose the pathnodes if it has already been created
if (pathNodes.IsCreated)
{
jobHandle.Complete();
pathNodes.Dispose();
}
pathNodes = new NativeArray<Vector3>(pathCreator.Path.ToArray(), Allocator.Persistent);
}
// called after when update functions have been called
private void Update()
{
// if the job is completed, complete the job,
// write some data to the ai and create a new job
if (jobHandle.IsCompleted)
{
// force job to complete
jobHandle.Complete();
// if this is the first time this job has been marked as isCompleted run this code,
// to avoid unneeded amount of times of writing to the enemies
if (!jobAlreadyCompleted)
{
//mark this job as already been done once
jobAlreadyCompleted = true;
// check if the enemy count is higher then 0,
// and if all the arrays are made (by checking the main one)
if (enemiesToUpdate.Count > 0 && enemyTransforms.isCreated)
{
// Update target indices of the enemies using information from the job
for (int i = 0; i < enemiesToUpdate.Count; i++)
{
enemiesToUpdate[i].TargetNodeIndex = targetNodeIndices[i];
if (enemiesToUpdate[i].TargetNodeIndex >= pathReachedEndIndex)
{
enemiesToUpdate[i].HasReachedEnd();
}
}
}
// Dispose of the data that changes between jobs to free up space
DisposeMost();
}
// Create and run a new job only if there are enemies present
if (enemies.Count > 0)
{
jobAlreadyCompleted = false;
jobHandle = CreateJobData().Schedule(enemyTransforms);
}
}
}
public EnemyMoveJob CreateJobData()
{
enemiesToUpdate = new(enemies);
// create new native arrays with the length of enemies
enemyTransforms = new TransformAccessArray(enemiesToUpdate.Count);
speeds = new NativeArray<float>(enemiesToUpdate.Count, Allocator.Persistent);
offsets = new NativeArray<Vector3>(enemiesToUpdate.Count, Allocator.Persistent);
targetNodeIndices = new NativeArray<int>(enemiesToUpdate.Count, Allocator.Persistent);
// fill all the native arrays in a single for loop,
// skip hasReachedEnd because we fill it with false for every enemy
for (int i = 0; i < enemiesToUpdate.Count; i++)
{
EnemyBase enemy = enemiesToUpdate[i];
enemyTransforms.Add(enemy.transform);
speeds[i] = enemy.Speed;
offsets[i] = enemy.RandomOffset;
targetNodeIndices[i] = enemy.TargetNodeIndex;
}
//return the data as an EnemyMoveJob struct,
//this needs to be filled in fully, otherwise the job gets angry
return new()
{
DeltaTime = Time.deltaTime,
PathNodes = pathNodes,
Speeds = speeds,
Offsets = offsets,
TargetNodeIndices = targetNodeIndices,
};
}
// dispose of everything when this object is destroyed,
// otherwise unnecessary memory is used.
private void OnDestroy()
{
jobHandle.Complete();
DisposeMost();
if (pathNodes.IsCreated) pathNodes.Dispose();
}
/// <summary>
/// Dispose of data that changes between jobs to free up space.
/// (garbage collection doesn't happen automatically on native arrays,
/// you have to dispose of them yourself, otherwise you will get a memory leak.)
/// </summary>
private void DisposeMost()
{
if (enemyTransforms.isCreated) enemyTransforms.Dispose();
if (speeds.IsCreated) speeds.Dispose();
if (offsets.IsCreated) offsets.Dispose();
if (targetNodeIndices.IsCreated) targetNodeIndices.Dispose();
}
}
//burst compiler, very efficient, but limited unity functions,
//also garbage collection needs to be done by yourself
[BurstCompile]
public struct EnemyMoveJob : IJobParallelForTransform
{
// data as a struct, when you don't want a job to modify data make sure it's set to readonly
[ReadOnly] public float DeltaTime;
[ReadOnly] public NativeArray<Vector3> PathNodes;
[ReadOnly] public NativeArray<float> Speeds;
[ReadOnly] public NativeArray<Vector3> Offsets;
public NativeArray<int> TargetNodeIndices;
// Move every object with its own data
public void Execute(int index, TransformAccess transform)
{
//if target index is too big, just don't
if (TargetNodeIndices[index] >= PathNodes.Length)
{
Debug.LogWarning($"target index of enemy {index} was out of range of the path");
return;
}
// Get the information for this specific index
float speed = Speeds[index];
Vector3 offset = Offsets[index];
int targetNodeIndex = TargetNodeIndices[index];
// If target index is 0 (at the start of the path),
// position at the beginning and increment the target position
if (targetNodeIndex == 0)
{
transform.SetPositionAndRotation(PathNodes[targetNodeIndex], Quaternion.identity);
targetNodeIndex++;
TargetNodeIndices[index] = targetNodeIndex;
}
// save the current and target position
Vector3 currentPosition = transform.position;
Vector3 targetPosition = PathNodes[targetNodeIndex] + offset;
// Update the transform based on the distance you need to go
transform.position = Vector3.MoveTowards(currentPosition, targetPosition, speed * DeltaTime);
// Check if needs to be rotated, if so rotate
Vector3 direction = (targetPosition - currentPosition).normalized;
// If there's a valid direction, rotate smoothly
if (direction.sqrMagnitude > 0.0001f) // Avoid division by zero and insignificant values
{
// Get the target rotation based on the direction
Quaternion targetRotation = Quaternion.LookRotation(direction);
// Smoothly rotate from current rotation to target rotation
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, 5 * DeltaTime);
}
// Check if enemy has reached the target node with a tolerance
if (Vector3.Distance(transform.position, targetPosition) <= 0.01f) TargetNodeIndices[index]++;
}
}
SDF shader
Random Project
Projects
about
This is the second project i made for my self-study task for my study, using various youtube video's to slowly learn GLSL. The original shader was made to work within Shadertoy but had to be slightly modified to work on this website.
script.cs
A simple shader that creates funky patterns.
// Cosine-based palette with 4 vec3 parameters
vec3 palette(float t) {
vec3 a = vec3(1.088, 0.928, 0.928);
vec3 b = vec3(0.478, 0.848, 0.638);
vec3 c = vec3(0.903, 0.668, 0.608);
vec3 d = vec3(1.067, 0.797, 0.528);
return a + b * cos(6.283185 * (c * t + d));
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
// Normalize coordinates
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;
vec2 uv0 = uv;
vec3 finalColor = vec3(0.0);
for (float i = 0.0; i < 3.0; i++) {
vec3 col = vec3(0.0);
if (mod(i, 2.0) == 0.0) {
col = palette(length(uv0) + i * 0.4 + iTime / 2.0);
} else {
col = palette(length(uv0) + i * 0.4 - iTime / 2.0);
}
uv = fract(uv * 1.7) - 0.5;
float dist = length(uv);
float frequency = 10.0;
if (mod(i, 2.0) == 0.0) {
dist = sin(dist * frequency + iTime) / frequency;
} else {
dist = sin(dist * frequency - iTime) / frequency;
}
dist = abs(dist);
dist = 0.005 / dist;
finalColor += col * dist;
}
fragColor = vec4(finalColor, 1.0);
}
Tiny house
Random Project
Projects
about
A to-scale tiny house created in Blender. Everything in the scene was modeled by me, except for the grass. This 3D model was made for a school assignment for my software development degree.
Ray Marching
Random Project
Projects
about
The third project i made for my self-study task for my study, as i've always loved raymarched geometry i wanted to try and make it myself. I made a lot of changes to the shader to work with Three.js, in addition i improved some performance issues and just did general polish.
script.cs
All uniforms are passed to this script by three.js that i'm using to display this.
precision highp float;
// variables passed from the scene manager
uniform float u_time;
uniform vec2 u_resolution;
uniform vec3 u_camPos;
uniform vec3 u_camRot;
uniform float u_camNear;
uniform float u_camFar;
uniform float u_scrollModifier;
// basic structure to save data about an object
struct Transform {
vec3 position;
vec3 rotation;
vec3 scale;
};
Transform defaultTransform() {
Transform t;
t.position = vec3(0.0);
t.rotation = vec3(0.0);
t.scale = vec3(1.0);
return t;
}
// logic handling object rotation
vec3 rotateX(vec3 p, float angle) {
float c = cos(angle);
float s = sin(angle);
return vec3(p.x, c*p.y - s*p.z, s*p.y + c*p.z);
}
vec3 rotateY(vec3 p, float angle) {
float c = cos(angle);
float s = sin(angle);
return vec3(c*p.x + s*p.z, p.y, -s*p.x + c*p.z);
}
vec3 rotateZ(vec3 p, float angle) {
float c = cos(angle);
float s = sin(angle);
return vec3(c*p.x - s*p.y, s*p.x + c*p.y, p.z);
}
vec3 rot3D(vec3 p, vec3 rot) {
p = rotateX(p, rot.x);
p = rotateY(p, rot.y);
p = rotateZ(p, rot.z);
return p;
}
vec3 applyTransform(vec3 pos, Transform t) {
pos -= t.position;
pos = rot3D(pos, t.rotation);
pos /= t.scale;
return pos;
}
// SDF's for specific shapes
float sdSphere(vec3 p, float s) {
return length(p) - s;
}
float sdBox(vec3 p, vec3 b) {
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
// operators to combine 2 objects
float opSmoothUnion(float d1, float d2, float k) {
float h = clamp(0.5 + 0.5*(d2-d1)/k, 0.0, 1.0);
return mix(d2, d1, h) - k*h*(1.0-h);
}
// color palette
vec3 palette(float t) {
vec3 a = vec3(0.681, 0.768, 0.968);
vec3 b = vec3(0.245, 0.764, 0.721);
vec3 c = vec3(1.202, 0.362, 0.660);
vec3 d = vec3(0.481, 3.592, 5.705);
return a + b*cos(6.283185*(c*t + d));
}
// returns the local position inside the cell (centered around 0)
// and outputs the integer cell ID
vec3 fractCell(vec3 worldPos, float cellSize, out vec3 cellId) {
vec3 scaledPos = worldPos / cellSize;
cellId = floor(scaledPos);
return fract(scaledPos) * cellSize - cellSize * 0.5;
}
// Helper to get a random value based on a position
float randomValue(vec3 cellId) {
cellId = fract(cellId * 0.3183099 + 0.1);
cellId *= 17.0;
return fract(cellId.x * cellId.y * cellId.z * (cellId.x + cellId.y + cellId.z));
}
// create the object that is repeated across the scene
float cubeSphere(vec3 worldPos) {
Transform boxTransform = defaultTransform();
vec3 cellId;
float cellSize = 2.0 * u_scrollModifier;
// get repeated local position and cell id
vec3 repeatedPos = fractCell(worldPos + vec3(0.0, 0.0, u_time), cellSize, cellId);
// random size per cell
float randSize = randomValue(cellId);
float size = 0.4 + randSize * 0.1;
// random rotation per cell
boxTransform.rotation = vec3(
u_time + randSize * 2.0,
u_time + randSize * 2.0,
u_time + randSize * 2.0
);
vec3 localPos = applyTransform(repeatedPos, boxTransform);
float box = sdBox(localPos, vec3(size));
float sphere = sdSphere(localPos, size);
// smooth saw tooth sine
float sine = sin(u_time * 6.283185 / 8.0);
float y = tanh(sine * 5.0) * 0.5 + 0.5;
return mix(box, sphere, y);
}
// holds the entire scene to do raymarching on
float scene(vec3 worldPos) {
Transform sphereTransform = defaultTransform();
float sphere = sdSphere(applyTransform(worldPos, sphereTransform), 1.0);
float cubeSphereVal = cubeSphere(worldPos);
return opSmoothUnion(sphere, cubeSphereVal, 0.4);
}
// Estimate gradient (normal) of the SDF at a point within the scene
vec3 estimateNormal(vec3 p) {
float eps = 0.001;
float dx = scene(p + vec3(eps, 0.0, 0.0)) - scene(p - vec3(eps, 0.0, 0.0));
float dy = scene(p + vec3(0.0, eps, 0.0)) - scene(p - vec3(0.0, eps, 0.0));
float dz = scene(p + vec3(0.0, 0.0, eps)) - scene(p - vec3(0.0, 0.0, eps));
return normalize(vec3(dx, dy, dz));
}
// handles the shading of the scene, in a toony style
vec3 shadeScene(vec3 sceneColor, vec3 hitPos, vec3 rayDir, float rayDistance) {
vec3 normal = vec3(0.0);
vec3 lightDir = normalize(vec3(-1.0, 1.0, -1.0));
float lighting = 1.0;
bool hit = (rayDistance < u_camFar);
if(hit) {
normal = estimateNormal(hitPos);
// Lambertian shading
float diffuse = clamp(dot(normal, lightDir), 0.0, 1.0);
int steps = 3;
diffuse = floor(diffuse * float(steps)) / float(steps);
// Add ambient
float ambient = 0.7;
lighting = ambient + (1.0 - ambient) * diffuse;
}
return hit ? sceneColor * lighting : sceneColor;
}
void main() {
vec2 uv = (gl_FragCoord.xy * 2.0 - u_resolution.xy) / u_resolution.y;
vec3 col = vec3(0.0);
Transform cameraTransform = defaultTransform();
cameraTransform.position = u_camPos;
vec3 rayDir = rot3D(normalize(vec3(uv, 1.0)), u_camRot);
// the actual raymarching
float rayDistance = 0.0;
vec3 hitPos;
int i;
for(i = 0; i < 100; i++){
hitPos = cameraTransform.position + rayDir * rayDistance;
float distance = scene(hitPos);
rayDistance += distance;
if(distance < u_camNear || rayDistance > u_camFar) break;
}
// Base color from depth, and add shading
col = palette(rayDistance / u_camFar * 2.5);
col = shadeScene(col, hitPos, rayDir, rayDistance);
gl_FragColor = vec4(col, 1.0);
}
Howling moon
Random Project
Projects
about
Howling moon is a solo school project i worked on for ~3 weeks, i made the game logic, all 3D assets and more. The goal of the game is to fight, gather stronger cards by summoning them with currency. The main game functions were done by the end of the project, but balancing and refining the loop to feel more rewarding weren't achievable.
GameLoop.cs
PlayField_Deck.cs
PlayField_User.cs
Handles the main game loop, disables all player actions until it's actually the players turn, Also triggers the cards to attack each other or the player/enemy depending on the playfield states.
using System.Collections;
using UnityEngine;
public class GameLoop : MonoBehaviour
{
int playerHealth = 3;
int AIHealth = 3;
bool hasPlayerEndedTurn = false;
bool hasAIEndedTurn = false;
[SerializeField] private AI ai;
[SerializeField] private AIDialouge_Tutorial dialouge;
[Header("Decks")]
[SerializeField] private PlayField_Deck playerDeck;
[SerializeField] private PlayField_Deck aiDeck;
[Header("Playfields")]
[SerializeField] private PlayField_User_Player playerUser;
[SerializeField] private PlayField_User_AI aiUser;
private void Start()
{
StartCoroutine(Loop());
}
private void OnDisable()
{
StopAllCoroutines();
}
private IEnumerator Loop()
{
while (CheckGameState() == 0)
{
yield return StartCoroutine(Round());
}
switch(CheckGameState())
{
case 1: print("Game is a tie"); break;
case 2: print("Player Won"); break;
case 3: print("AI Won"); break;
}
}
private IEnumerator Round()
{
yield return StartCoroutine(PlayerRound());
yield return StartCoroutine(AIRound());
yield return StartCoroutine(Battle());
}
private IEnumerator Battle()
{
yield return StartCoroutine(Fight(3));
yield return new WaitForSeconds(1);
yield return StartCoroutine(Fight(4));
}
private IEnumerator Fight(int slot)
{
// if both slots are occupied
if (playerUser.AllCardSlots[slot].IsOccupied && aiUser.AllCardSlots[slot].IsOccupied)
{
// get both cards
CardData playerCard = playerUser.AllCardSlots[slot].Card;
CardData aiCard = aiUser.AllCardSlots[slot].Card;
// both cards damage eachother
playerCard.cardHealth -= aiCard.cardDamage;
aiCard.cardHealth -= playerCard.cardDamage;
// animate the damage effect
playerUser.AllCardSlots[slot].CardObj.GetComponent<Animator>().SetBool("TakeDamage", true);
aiUser.AllCardSlots[slot].CardObj.GetComponent<Animator>().SetBool("TakeDamage", true);
yield return new WaitForSeconds(0.1f);
playerUser.AllCardSlots[slot].CardObj.GetComponent<Animator>().SetBool("TakeDamage", false);
aiUser.AllCardSlots[slot].CardObj.GetComponent<Animator>().SetBool("TakeDamage", false);
// update ui
playerUser.AllCardSlots[slot].CardObj.GetComponent<CardUI>().SetCardValues();
aiUser.AllCardSlots[slot].CardObj.GetComponent<CardUI>().SetCardValues();
// if player card died
if (playerCard.cardHealth <= 0)
{
playerUser.AllCardSlots[slot].Card = null;
playerUser.GenerateObjects();
}
// if ai card died
if (aiCard.cardHealth <= 0)
{
Player.Instance.PlayerData.currency += 5;
aiUser.AllCardSlots[slot].Card = null;
aiUser.GenerateObjects();
}
}
// only player card
else if (playerUser.AllCardSlots[slot].IsOccupied && !aiUser.AllCardSlots[slot].IsOccupied)
AIHealth--;
// only ai card
else if (!playerUser.AllCardSlots[slot].IsOccupied && aiUser.AllCardSlots[slot].IsOccupied)
playerHealth--;
yield return null;
}
private IEnumerator PlayerRound()
{
playerUser.IsAllowedToPlace = true;
// allow the player to grab the cards
playerDeck.CanGrab = true;
// wait until the player has grabbed a card
yield return new WaitUntil(() => playerDeck.HasGrabbed);
// disable the deck
playerDeck.CanGrab = false;
playerDeck.HasGrabbed = false;
// wait until player has finished turn, once done, reset value
hasPlayerEndedTurn = false;
yield return new WaitUntil(() => hasPlayerEndedTurn);
hasPlayerEndedTurn = false;
// tutorial logic
if (!AIDialouge_Tutorial.Instance.HasFinishedTutorial)
if (AIDialouge_Tutorial.Instance.TutorialStep == 7)
AIDialouge_Tutorial.Instance.TutorialStep++;
playerUser.IsAllowedToPlace = false;
yield return null;
}
private IEnumerator AIRound()
{
yield return StartCoroutine(ai.GrabCard());
yield return new WaitForSeconds(0.5f);
yield return StartCoroutine(ai.TakeTurn());
}
private int CheckGameState()
{
// defaults to 0, sadly never got to the point of making a win condition
return 0;
// return 1 if the game is a tie,
// 2 if the player won,
// 3 if the AI won,
// 0 if no one won yet.
if (playerHealth <= 0 && AIHealth <= 0) return 1;
else if (playerHealth > 0 && AIHealth <= 0) return 2;
else if (playerHealth <= 0 && AIHealth > 0) return 3;
else return 0;
}
public bool HasPlayerEndedTurn { set { hasPlayerEndedTurn = value; } }
public bool HasAIEndedTurn { set { hasAIEndedTurn = value; } }
}
Handles the decks of both the player and ai (both have an inherited version with extra methods for their own use). Creates a physical pile within the scene based on the deck size, also handles the hovering animation.
using UnityEngine;
public abstract class PlayField_Deck : MonoBehaviour, IInteractable
{
[Header("Hover effect")]
[SerializeField] protected Animator animator;
[Header("Card stack size changing")]
[SerializeField] protected GameObject[] cards;
protected bool canGrab = false;
protected bool hasGrabbed = false;
protected CardData[] deck;
[SerializeField] protected Hand hand;
private void Start()
{
UpdateDeck();
}
public virtual void UpdateDeck()
{
//disable all cards
foreach (GameObject card in cards) card.SetActive(false);
cards[0].SetActive(true);
for (int i = 0; i < deck.Length; i++)
{
cards[i].SetActive(true);
}
}
public void OnInteract()
{
// tutorial logic
if (!AIDialouge_Tutorial.Instance.HasFinishedTutorial)
if (AIDialouge_Tutorial.Instance.TutorialStep == 4)
AIDialouge_Tutorial.Instance.TutorialStep++;
GrabFromDeck();
}
public void GrabFromDeck()
{
if (canGrab && !hasGrabbed)
{
UpdateDeck();
if (deck.Length != 0 && hand.Cards.Count < 19)
{
int randomIndex = Random.Range(0, deck.Length);
hand.AddToHand(deck[randomIndex]);
}
hasGrabbed = true;
}
}
public bool CanGrab
{
get { return canGrab; } set { canGrab = value; }
}
public bool HasGrabbed
{
get { return hasGrabbed; } set { hasGrabbed = value;}
}
// hover effect
public virtual void OnMouseEnter()
{
if (canGrab)
{
animator.SetBool("Hover", true);
}
}
public virtual void OnMouseExit()
{
animator.SetBool("Hover", false);
}
}
Keeps track on which playfield slots are available for both player and ai (both have an inherited version with extra methods for their own use). Creates the physical cards and also handles the hovering/selection behavior when interacting with both an empty slot and filled slot. This project was made by a team of 5 people.
using System;
using UnityEngine;
public abstract class PlayField_User : MonoBehaviour
{
[SerializeField] private GameObject card;
[Header("Slots")]
[SerializeField] private CardSlot[] allCardSlots;
[SerializeField] protected Hand hand;
private bool isAllowedToPlace = true;
private CardSlot selectedSlot;
public void InteractWithField(int slotIndex)
{
if (!isAllowedToPlace) return;
// grab the slot
CardSlot slot = allCardSlots[slotIndex];
// reset selected slot if the slot is already in use
if (slot.IsOccupied && selectedSlot != null && selectedSlot.CardObj != null)
{
if (selectedSlot.CardObj.TryGetComponent(out Animator animator))
animator.SetBool("Selected", false);
selectedSlot = null;
return;
}
// if there is a card in your hand, else try to run move
if (hand.SelectedCard != null && !slot.IsActive) PlaceCard(slot);
else if (selectedSlot != null && selectedSlot.IsOccupied) MoveCard(slot);
}
public void PlaceCard(CardSlot slot)
{
// tutorial logic
if (!AIDialouge_Tutorial.Instance.HasFinishedTutorial)
if (AIDialouge_Tutorial.Instance.TutorialStep == 5)
AIDialouge_Tutorial.Instance.TutorialStep++;
selectedSlot = null;
// get the selected card
CardData cardData = hand.SelectedCard.CardData;
cardData.cardCorruption++;
// copy the card data into the slot
slot.Card = cardData.Copy();
// remove the selected card from the hand
hand.RemoveFromHand(cardData);
// unassign the card from the hand
hand.RemoveSelected();
GenerateObjects();
}
public void MoveCard(CardSlot slot)
{
// tutorial logic
if (!AIDialouge_Tutorial.Instance.HasFinishedTutorial)
if (AIDialouge_Tutorial.Instance.TutorialStep == 6)
AIDialouge_Tutorial.Instance.TutorialStep++;
// move the data to the active slot
slot.Card = selectedSlot.Card;
// clear the inactive slot info
selectedSlot.Card = null;
GenerateObjects();
}
public void SelectSlot(CardUI_PlayField card) => SelectSlot(card.slot);
public void SelectSlot(CardSlot slot)
{
foreach (CardSlot cardSlot in allCardSlots)
if (cardSlot.IsOccupied)
cardSlot.CardObj.GetComponent<Animator>().SetBool("Selected", false);
if (slot != selectedSlot)
{
slot.CardObj.GetComponent<Animator>().SetBool("Selected", true);
selectedSlot = slot;
}
else
{
slot.CardObj.GetComponent<Animator>().SetBool("Selected", false);
selectedSlot = null;
}
}
public void GenerateObjects()
{
// first clear the game objects, then regenerate the slots
foreach (CardSlot slot in allCardSlots)
{
if (slot.CardObj != null) Destroy(slot.CardObj);
// check if there is data, if not skip
if (slot.Card != null)
{
slot.CardObj = Instantiate(card, slot.target.position, slot.target.rotation);
slot.CardObj.transform.SetParent(slot.target, true);
CardUI_PlayField script = slot.CardObj.GetComponent<CardUI_PlayField>();
script.SetCardValues(slot.Card);
script.slot = slot;
script.interact += SelectSlot;
}
}
}
public CardSlot[] AllCardSlots => allCardSlots;
public bool IsAllowedToPlace
{
get { return isAllowedToPlace; }
set { isAllowedToPlace = value; }
}
}
[Serializable]
public record CardSlot
{
public Transform target;
public bool IsActive;
private CardData cardData;
private GameObject cardObj;
public bool IsOccupied
{
get => cardData != null;
}
public GameObject CardObj
{
get => cardObj;
set => cardObj = value;
}
public CardData Card
{
get => cardData;
set => cardData = value;
}
}
Geiger//Counter
Random Project
Projects
about
Geiger//Counter is a card game where you explore a randomly generated map, fight monsters using cards with energy costs as actions (like attacking, adding shields, etc). During the journey you can collect relics that give you permenant bonuses after fights.
MapBuilder.cs
MapNode.cs
MapNodeBattle.cs
MapNodeShop.cs
Creates both the data and visual map, the map generation is fully seed-based, allowing you to re-generate the same map each time based on said seed.
using MyBox;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI.Extensions;
public class MapBuilder : MonoBehaviour, ISeededScene
{
#region fields
[Foldout("Gen", true)]
[SerializeField, MinMaxRange(3, 20)]
private RangedInt minMaxWidth = new(5, 7);
[SerializeField, MinMaxRange(3, 10)]
private RangedInt minMaxHeight = new(3, 5);
[Foldout("Prefabs", true)]
[Header("Layout")]
[SerializeField]
private UILineRenderer linePrefab;
[SerializeField]
private RectTransform columnPrefab;
[Header("Nodes")]
[SerializeField]
private MapNodeStart mapNodeStartPrefab;
[SerializeField]
private MapNodeEnd mapNodeEndPrefab;
[SerializeField]
private MapNodeShop mapNodeShopPrefab;
[SerializeField]
private MapNodeBattle mapNodeBattlePrefab;
[Foldout("parents", true), SerializeField]
private RectTransform mapNodeParent;
[SerializeField]
private RectTransform lineParent;
private int height = 0;
private int width = 0;
private MapNode[,] mapNodes;
private System.Random rng;
private List<RectTransform> objectsToMove = new();
private int seed = 0;
private bool mapGenerated = false;
public Bounds MapBounds { get; private set; }
#endregion
#region entry points
private void Awake()
{
SaveSystem.LoadRequested -= LoadOwnData;
SaveSystem.LoadRequested += LoadOwnData;
SaveSystem.SaveRequested -= SaveOwnData;
SaveSystem.SaveRequested += SaveOwnData;
MusicManager.Instance.SetSongDefault();
}
private void OnDestroy()
{
SaveSystem.LoadRequested -= LoadOwnData;
SaveSystem.SaveRequested -= SaveOwnData;
}
public void SetSeed(int seed)
{
this.seed = seed;
CreateNewMap(seed);
}
public void CreateMapFromSave(MapBuilderData data)
{
print($"save data exists, loading seed");
// check if we have data, if not, generate new map
seed = data.seed;
rng = new System.Random(seed);
width = rng.Next(minMaxWidth.Min, minMaxWidth.Max + 1);
height = rng.Next(minMaxHeight.Min, minMaxHeight.Max + 1);
mapNodes = new MapNode[width, height];
CreateMap();
// apply saved data
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
MapNode node = mapNodes[x, y];
if (node == null) continue;
if (data.GetNodeCompleted(x, y)) // use Get(x,y) for the flattened array
{
node.CompleteNode();
}
}
}
}
public void CreateNewMap(int seed)
{
rng = new System.Random(seed);
width = rng.Next(minMaxWidth.Min, minMaxWidth.Max + 1);
height = rng.Next(minMaxHeight.Min, minMaxHeight.Max + 1);
mapNodes = new MapNode[width, height];
CreateMap();
SaveOwnData();
}
#endregion
#region map generation
private void CreateMap()
{
// clear old map
foreach (RectTransform child in objectsToMove)
Destroy(child.gameObject);
objectsToMove.Clear();
BuildRandomLayout();
ConnectNodes();
MoveMap();
mapGenerated = true;
}
#region layout generation
private void BuildRandomLayout()
{
for (int x = 0; x < width; x++)
{
RectTransform parent = Instantiate(columnPrefab, mapNodeParent);
parent.name = "Column_" + x;
parent.anchoredPosition = new Vector2((x * 600f) - (parent.rect.width / 2), 0f);
// start node
if (x == 0)
{
CreateNode(parent, x, rng.Next(0, height), mapNodeStartPrefab);
continue;
}
// end node
if (x == width - 1)
{
CreateNode(parent, x, rng.Next(0, height), mapNodeEndPrefab);
continue;
}
if (x == 1)
{
List<int> created = new();
while (created.Count < minMaxHeight.Min)
{
int y = rng.Next(0, height);
if (created.Contains(y))
continue;
created.Add(y);
CreateNode(parent, x, y, mapNodeBattlePrefab);
}
continue;
}
int shopY = rng.Next(0, height);
int battleY;
do
{
battleY = rng.Next(0, height);
}
while (battleY == shopY);
CreateNode(parent, x, shopY, mapNodeShopPrefab);
CreateNode(parent, x, battleY, mapNodeBattlePrefab);
for (int y = 0; y < height; y++)
{
if (y == shopY || y == battleY)
continue;
if (rng.Next(100) < 50)
CreateNode(parent, x, y, mapNodeBattlePrefab);
}
}
}
private void CreateNode(RectTransform parent, int x, int y, MapNode prefab)
{
MapNode node = Instantiate(prefab, parent.transform);
RectTransform nodeRect = node.GetComponent<RectTransform>();
// reset anchors and pivot to bottom-left
nodeRect.anchorMin = nodeRect.anchorMax = new Vector2(0, 0);
nodeRect.pivot = new Vector2(0, 0);
nodeRect.anchoredPosition = new Vector2(0, y * 250f);
mapNodes[x, y] = node;
objectsToMove.Add(nodeRect);
}
#endregion
#region node connection
private void ConnectNodes()
{
for (int x = 0; x < width; x++)
{
int nextX = x + 1;
for (int y = 0; y < height; y++)
{
MapNode node = mapNodes[x, y];
if (node == null) continue;
List<MapNode> connected = new();
bool hasConnection = false;
// force all connections from first column
if (x == 0)
{
// try to connect to nearby nodes first
for (int nextY = 0; nextY < height; nextY++)
{
MapNode target = mapNodes[1, nextY];
if (target == null) continue;
target.SetParentNode(node);
connected.Add(target);
hasConnection = true;
}
}
if (nextX > width - 1) continue;
// try to connect to nearby nodes first
if (!hasConnection)
{
for (int nextY = 0; nextY < height; nextY++)
{
MapNode target = mapNodes[nextX, nextY];
if (target == null) continue;
// first pass: allow direct or diagonal
bool allowConnection = nextY == y || nextY == y + 1 || nextY == y - 1;
if (!hasConnection && allowConnection)
{
target.SetParentNode(node);
connected.Add(target);
hasConnection = true;
break;
}
}
}
// if we didn’t connect to any nearby node, allow connection to first available
if (!hasConnection)
{
for (int nextY = 0; nextY < height; nextY++)
{
MapNode target = mapNodes[nextX, nextY];
if (target == null) continue;
target.SetParentNode(node);
connected.Add(target);
hasConnection = true;
break;
}
}
int nodeSeed = rng.Next(0, int.MaxValue);
node.Initialize(x == 0, connected, nodeSeed);
}
}
for (int x = width - 1; x > 0; x--)
{
int prevX = x - 1;
for (int y = height - 1; y >= 0; y--)
{
// make sure this node hasn't already been assigned a parent
MapNode node = mapNodes[x, y];
if (node == null || node.ParentNode != null) continue;
bool parentFound = false;
if (!parentFound)
{
for (int prevY = height - 1; prevY >= 0; prevY--)
{
MapNode target = mapNodes[prevX, prevY];
if (target == null) continue;
bool isDirect = prevY == y;
bool isDiagonal = prevY == y - 1 || prevY == y + 1;
if (!parentFound && (isDirect || isDiagonal))
{
node.SetParentNode(target);
parentFound = true;
target.ConnectedNodes.Add(node);
int nodeSeed = rng.Next(0, int.MaxValue);
target.Initialize(x == width - 1, target.ConnectedNodes, nodeSeed);
break;
}
}
}
if (!parentFound)
{
for (int prevY = height - 1; prevY >= 0; prevY--)
{
MapNode target = mapNodes[prevX, prevY];
if (target == null) continue;
node.SetParentNode(target);
parentFound = true;
target.ConnectedNodes.Add(node);
int nodeSeed = rng.Next(0, int.MaxValue);
target.Initialize(x == width - 1, target.ConnectedNodes, nodeSeed);
break;
}
}
}
}
BuildLines();
}
private void BuildLines()
{
for (int x = 0; x < width - 1; x++)
{
for (int y = 0; y < height; y++)
{
MapNode node = mapNodes[x, y];
if (node == null) continue;
foreach (MapNode target in node.ConnectedNodes)
{
UILineRenderer line = Instantiate(linePrefab, lineParent);
line.Points = new Vector2[]
{
GetRelativePosition(lineParent, node.Rect),
GetRelativePosition(lineParent, target.Rect)
};
objectsToMove.Add(line.GetComponent<RectTransform>());
}
}
}
}
#endregion
public void MoveMap()
{
if (objectsToMove == null || objectsToMove.Count == 0) return;
// Force UI to update so RectTransforms have correct world positions
Canvas.ForceUpdateCanvases();
// Step 1: Calculate world-space bounds based on all node corners
bool first = true;
Bounds bounds = new Bounds();
foreach (RectTransform rect in objectsToMove)
{
Vector3[] corners = new Vector3[4];
rect.GetWorldCorners(corners); // bottom-left, top-left, top-right, bottom-right
foreach (Vector3 corner in corners)
{
if (first)
{
bounds = new Bounds(corner, Vector3.zero);
first = false;
}
else
{
bounds.Encapsulate(corner);
}
}
}
// Step 2: Move map so its center is at (0,0) in world space
Vector3 delta = -bounds.center;
foreach (RectTransform rect in objectsToMove)
{
Vector3 localDelta = rect.parent.InverseTransformVector(delta);
rect.anchoredPosition += (Vector2)localDelta;
}
bounds.center = Vector3.zero;
MapBounds = bounds;
}
#endregion
#region save/load
private void LoadOwnData()
{
MapBuilderData data = SaveSystem.LoadData<MapBuilderData>("mapData");
if (data.HasData)
CreateMapFromSave(data);
}
public void SaveOwnData()
{
if (!mapGenerated) return;
MapBuilderData data = new();
data.Init(width, height, seed);
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
MapNode node = mapNodes[x, y];
if (node == null) continue;
data.SetNodeCompleted(x, y, node.NodeCompleted);
}
}
SaveSystem.SaveData("mapData", data);
}
#endregion
private Vector2 GetRelativePosition(RectTransform from, RectTransform to)
{
// World position of the target
Vector3 worldPos = to.TransformPoint(to.rect.center);
// Convert into local space of source RectTransform
RectTransformUtility.ScreenPointToLocalPointInRectangle(
from,
RectTransformUtility.WorldToScreenPoint(null, worldPos),
null,
out Vector2 localPos
);
return localPos;
}
}
Map node is the abstract class used for all nodes on the map, it allows you to keep track of their completion status and their child nodes (nodes connected to the right of this node). This also holds a pre-defined seed given by the map builder.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public abstract class MapNode : MonoBehaviour
{
[SerializeField]
private Button button;
private bool enabledNode = false;
public bool NodeCompleted { get; private set; } = false;
public RectTransform Rect { get; private set; }
public MapNode ParentNode { get; private set; }
public List<MapNode> ConnectedNodes { get; private set; } = new List<MapNode>();
public int NodeSeed { get; private set; }
private void Awake() => Rect = GetComponent<RectTransform>();
public void Initialize(bool startingNode, List<MapNode> connectedNodes, int nodeSeed)
{
enabledNode = startingNode;
ConnectedNodes = connectedNodes;
NodeSeed = nodeSeed;
button.interactable = enabledNode;
}
public void SetNodeState(bool state = true)
{
enabledNode = state;
button.interactable = state;
}
public void TriggerNodeAction()
{
SetNodeState(false);
CompleteNode();
InheritNodeAction();
}
protected abstract void InheritNodeAction();
[ContextMenu("Complete Node")]
public void CompleteNode()
{
ConnectedNodes.ForEach(n => n.SetNodeState(true));
NodeCompleted = true;
button.interactable = false;
}
public void SetParentNode(MapNode parent) => ParentNode = parent;
}
Inherits from MapNode, modifies the behavior of clicking on the node, saving the game and loading the fight scene based on the pre-defined seed of MapNode.
using System.Collections;
using UnityEngine;
public class MapNodeBattle : MapNode
{
protected override void InheritNodeAction()
{
SaveSystem.SaveGame();
SceneLoader.Instance.LoadSeededScene(SceneType.Combat, NodeSeed);
}
}
Inherits from MapNode, modifies the behavior of clicking on the node, saving the game and loading the shop scene based on the pre-defined seed of MapNode.
using System.Collections;
using UnityEngine;
public class MapNodeShop : MapNode
{
protected override void InheritNodeAction()
{
SaveSystem.SaveGame();
SceneLoader.Instance.LoadSeededScene(SceneType.ShopMenu, NodeSeed);
}
}
About me
Experiences
Education
Projects
about
contribution
gallery
code
about
3D model
about
contribution
gallery
code
about
Shader
code
about
gallery
3D model
about
Shader
code
about
gallery
3D model
code
about
gallery
code
Fun fact: The entire website is fully made from scratch, designed in fimga.
Enschede, Netherlands