diff options
author | Oleg Kosenkov <oleg@kosenkov.ca> | 2022-11-06 17:58:44 -0500 |
---|---|---|
committer | Sam Atkins <atkinssj@gmail.com> | 2022-12-12 17:30:04 +0000 |
commit | 28bb3367cb11bf91aadf9d0b7b88957f7df85339 (patch) | |
tree | 6f190841a9d427db53de3849cab675a2f6b7f9db /Userland/Games/ColorLines | |
parent | d987ddc0ee080e6c047ac8af4199625f417ba346 (diff) | |
download | serenity-28bb3367cb11bf91aadf9d0b7b88957f7df85339.zip |
Games: Add ColorLines
Diffstat (limited to 'Userland/Games/ColorLines')
-rw-r--r-- | Userland/Games/ColorLines/CMakeLists.txt | 13 | ||||
-rw-r--r-- | Userland/Games/ColorLines/ColorLines.cpp | 400 | ||||
-rw-r--r-- | Userland/Games/ColorLines/ColorLines.h | 90 | ||||
-rw-r--r-- | Userland/Games/ColorLines/HueFilter.h | 51 | ||||
-rw-r--r-- | Userland/Games/ColorLines/Marble.h | 46 | ||||
-rw-r--r-- | Userland/Games/ColorLines/MarbleBoard.h | 356 | ||||
-rw-r--r-- | Userland/Games/ColorLines/MarblePath.h | 64 | ||||
-rw-r--r-- | Userland/Games/ColorLines/main.cpp | 75 |
8 files changed, 1095 insertions, 0 deletions
diff --git a/Userland/Games/ColorLines/CMakeLists.txt b/Userland/Games/ColorLines/CMakeLists.txt new file mode 100644 index 0000000000..12ee78ddb5 --- /dev/null +++ b/Userland/Games/ColorLines/CMakeLists.txt @@ -0,0 +1,13 @@ +serenity_component( + ColorLines + RECOMMENDED + TARGETS ColorLines +) + +set(SOURCES + ColorLines.cpp + main.cpp +) + +serenity_app(ColorLines ICON app-colorlines) +target_link_libraries(ColorLines PRIVATE LibGUI LibCore LibGfx LibConfig LibMain LibDesktop) diff --git a/Userland/Games/ColorLines/ColorLines.cpp b/Userland/Games/ColorLines/ColorLines.cpp new file mode 100644 index 0000000000..f8b4b095f3 --- /dev/null +++ b/Userland/Games/ColorLines/ColorLines.cpp @@ -0,0 +1,400 @@ +/* + * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca> + * Copyright (c) 2022, the SerenityOS developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "ColorLines.h" +#include "HueFilter.h" +#include "Marble.h" +#include "MarbleBoard.h" +#include <AK/String.h> +#include <LibConfig/Client.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Painter.h> +#include <LibGfx/Font/Emoji.h> + +ColorLines::BitmapArray ColorLines::build_marble_color_bitmaps() +{ + auto marble_bitmap = MUST(Gfx::Bitmap::try_load_from_file("/res/icons/colorlines/colorlines.png"sv)); + float constexpr hue_degrees[Marble::number_of_colors] = { + 0, // Red + 45, // Brown/Yellow + 90, // Green + 180, // Cyan + 225, // Blue + 300 // Purple + }; + BitmapArray colored_bitmaps; + colored_bitmaps.ensure_capacity(Marble::number_of_colors); + for (int i = 0; i < Marble::number_of_colors; ++i) { + auto bitmap = MUST(marble_bitmap->clone()); + HueFilter filter { hue_degrees[i] }; + filter.apply(*bitmap, bitmap->rect(), *marble_bitmap, marble_bitmap->rect()); + colored_bitmaps.append(bitmap); + } + return colored_bitmaps; +} + +ColorLines::BitmapArray ColorLines::build_marble_trace_bitmaps() +{ + // Use "Paw Prints" Unicode Character (U+1F43E) + auto trace_bitmap = NonnullRefPtr<Gfx::Bitmap>(*Gfx::Emoji::emoji_for_code_point(0x1F43E)); + BitmapArray result; + result.ensure_capacity(number_of_marble_trace_bitmaps); + result.append(trace_bitmap); + result.append(MUST(result.last()->rotated(Gfx::RotationDirection::Clockwise))); + result.append(MUST(result.last()->rotated(Gfx::RotationDirection::Clockwise))); + result.append(MUST(result.last()->rotated(Gfx::RotationDirection::Clockwise))); + return result; +} + +ColorLines::ColorLines(StringView app_name) + : m_app_name { app_name } + , m_game_state { GameState::Idle } + , m_board { make<MarbleBoard>() } + , m_marble_bitmaps { build_marble_color_bitmaps() } + , m_trace_bitmaps { build_marble_trace_bitmaps() } + , m_score_font { Gfx::BitmapFont::load_from_file("/res/fonts/MarietaBold24.font") } +{ + VERIFY(m_marble_bitmaps.size() == Marble::number_of_colors); + set_font(Gfx::FontDatabase::default_fixed_width_font().bold_variant()); + m_high_score = Config::read_i32(m_app_name, m_app_name, "HighScore"sv, 0); + reset(); +} + +void ColorLines::reset() +{ + set_game_state(GameState::StartingGame); +} + +void ColorLines::mousedown_event(GUI::MouseEvent& event) +{ + if (m_game_state != GameState::Idle && m_game_state != GameState::MarbleSelected) + return; + auto const event_position = event.position().translated( + -frame_inner_rect().x(), + -frame_inner_rect().y() - board_vertical_margin); + if (event_position.x() < 0 || event_position.y() < 0) + return; + auto const clicked_cell = Point { event_position.x() / board_cell_dimension.width(), + event_position.y() / board_cell_dimension.height() }; + if (!MarbleBoard::in_bounds(clicked_cell)) + return; + if (m_board->has_selected_marble()) { + auto const selected_cell = m_board->selected_marble().position(); + if (selected_cell == clicked_cell) { + m_board->reset_selection(); + set_game_state(GameState::Idle); + return; + } + if (m_board->is_empty_cell_at(clicked_cell)) { + if (m_board->build_marble_path(selected_cell, clicked_cell, m_marble_path)) + set_game_state(GameState::MarbleMoving); + return; + } + if (m_board->select_marble(clicked_cell)) + set_game_state(GameState::MarbleSelected); + return; + } + if (m_board->select_marble(clicked_cell)) + set_game_state(GameState::MarbleSelected); +} + +void ColorLines::timer_event(Core::TimerEvent&) +{ + switch (m_game_state) { + case GameState::GeneratingMarbles: + update(); + if (--m_marble_animation_frame < AnimationFrames::marble_generating_end) { + m_marble_animation_frame = AnimationFrames::marble_default; + set_game_state(GameState::CheckingMarbles); + } + break; + + case GameState::MarbleSelected: + m_marble_animation_frame = (m_marble_animation_frame + 1) % AnimationFrames::number_of_marble_bounce_frames; + update(); + break; + + case GameState::MarbleMoving: + m_marble_animation_frame = (m_marble_animation_frame + 1) % AnimationFrames::number_of_marble_bounce_frames; + update(); + if (m_marble_path.remaining_steps() != 1 && m_marble_animation_frame != AnimationFrames::marble_at_top) + break; + if (auto const point = m_marble_path.next_point(); m_marble_path.is_empty()) { + auto const color = m_board->selected_marble().color(); + m_board->reset_selection(); + m_board->set_color_at(point, color); + if (m_board->check_and_remove_marbles()) + set_game_state(GameState::MarblesRemoving); + else + set_game_state(GameState::GeneratingMarbles); + } + break; + + case GameState::MarblesRemoving: + update(); + if (++m_marble_animation_frame > AnimationFrames::marble_removing_end) { + m_marble_animation_frame = AnimationFrames::marble_default; + m_score += 2 * m_board->removed_marbles().size(); + set_game_state(GameState::Idle); + } + break; + + case GameState::StartingGame: + case GameState::Idle: + case GameState::CheckingMarbles: + break; + + case GameState::GameOver: { + stop_timer(); + update(); + StringBuilder text; + text.appendff("Your score is {}", m_score); + if (m_score > m_high_score) { + text.append("\nThis is a new high score!"sv); + Config::write_i32(m_app_name, m_app_name, "HighScore"sv, int(m_high_score = m_score)); + } + GUI::MessageBox::show(window(), + text.string_view(), + "Game Over"sv, + GUI::MessageBox::Type::Information); + reset(); + break; + } + + default: + VERIFY_NOT_REACHED(); + } +} + +void ColorLines::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + GUI::Painter painter(*this); + painter.add_clip_rect(frame_inner_rect()); + painter.add_clip_rect(event.rect()); + + auto paint_cell = [&](GUI::Painter& painter, Gfx::IntRect rect, int color, int animation_frame) { + painter.draw_rect(rect, Color::Black); + rect.shrink(0, 1, 1, 0); + painter.draw_line(rect.bottom_left(), rect.top_left(), Color::White); + painter.draw_line(rect.top_left(), rect.top_right(), Color::White); + painter.draw_line(rect.top_right(), rect.bottom_right(), Color::DarkGray); + painter.draw_line(rect.bottom_right(), rect.bottom_left(), Color::DarkGray); + rect.shrink(1, 1, 1, 1); + painter.draw_line(rect.bottom_left(), rect.top_left(), Color::LightGray); + painter.draw_line(rect.top_left(), rect.top_right(), Color::LightGray); + painter.draw_line(rect.top_right(), rect.bottom_right(), Color::MidGray); + painter.draw_line(rect.bottom_right(), rect.bottom_left(), Color::MidGray); + rect.shrink(1, 1, 1, 1); + painter.fill_rect(rect, tile_color); + rect.shrink(1, 1, 1, 1); + if (color >= 0 && color < Marble::number_of_colors) { + auto const source_rect = Gfx::IntRect { animation_frame * marble_pixel_size, 0, marble_pixel_size, marble_pixel_size }; + painter.draw_scaled_bitmap(rect, *m_marble_bitmaps[color], source_rect, + 1.0f, Gfx::Painter::ScalingMode::BilinearBlend); + } + }; + + painter.set_font(*m_score_font); + + // Draw board header with score, high score + auto board_header_size = frame_inner_rect().size(); + board_header_size.set_height(board_vertical_margin); + auto const board_header_rect = Gfx::IntRect { frame_inner_rect().top_left(), board_header_size }; + painter.fill_rect(board_header_rect, Color::Black); + + auto const text_margin = 8; + + // Draw score + auto const score_text = MUST(String::formatted("{:05}"sv, m_score)); + auto text_width { m_score_font->width(score_text) }; + auto const glyph_height = m_score_font->glyph_height(); + auto const score_text_rect = Gfx::IntRect { + frame_inner_rect().top_left().translated(text_margin), + Gfx::IntSize { text_width, glyph_height } + }; + painter.draw_text(score_text_rect, score_text, Gfx::TextAlignment::CenterLeft, text_color); + + // Draw high score + auto const high_score_text = MUST(String::formatted("{:05}"sv, m_high_score)); + text_width = m_score_font->width(high_score_text); + auto const high_score_text_rect = Gfx::IntRect { + frame_inner_rect().top_right().translated(-(text_margin + text_width), text_margin), + Gfx::IntSize { text_width, glyph_height } + }; + painter.draw_text(high_score_text_rect, high_score_text, Gfx::TextAlignment::CenterLeft, text_color); + + auto const cell_rect + = Gfx::IntRect(frame_inner_rect().top_left(), board_cell_dimension) + .translated(0, board_vertical_margin); + + // Draw all cells and the selected marble if it exists + for (int y = 0; y < MarbleBoard::board_size.height(); ++y) + for (int x = 0; x < MarbleBoard::board_size.width(); ++x) { + auto const& destination_rect = cell_rect.translated( + x * board_cell_dimension.width(), + y * board_cell_dimension.height()); + auto const point = Point { x, y }; + auto const animation_frame = m_game_state == GameState::MarbleSelected && m_board->has_selected_marble() + && m_board->selected_marble().position() == point + ? m_marble_animation_frame + : AnimationFrames::marble_default; + paint_cell(painter, destination_rect, m_board->color_at(point), animation_frame); + } + + // Draw preview marbles in the board + for (auto const& marble : m_board->preview_marbles()) { + auto const& point = marble.position(); + if (m_marble_path.contains(point) || !m_board->is_empty_cell_at(point)) + continue; + auto const& destination_rect = cell_rect.translated( + point.x() * board_cell_dimension.width(), + point.y() * board_cell_dimension.height()); + auto get_animation_frame = [this]() -> int { + switch (m_game_state) { + case GameState::GameOver: + return AnimationFrames::marble_default; + case GameState::GeneratingMarbles: + case GameState::CheckingMarbles: + return m_marble_animation_frame; + default: + return AnimationFrames::marble_generating_start; + } + }; + paint_cell(painter, destination_rect, marble.color(), get_animation_frame()); + } + + // Draw preview marbles in the board header + for (size_t i = 0; i < MarbleBoard::number_of_preview_marbles; ++i) { + auto const& marble = m_board->preview_marbles()[i]; + auto const& destination_rect = cell_rect.translated( + int(i + 3) * board_cell_dimension.width(), + -board_vertical_margin) + .shrunken(10, 10); + paint_cell(painter, destination_rect, marble.color(), AnimationFrames::marble_preview); + } + + // Draw moving marble + if (!m_marble_path.is_empty()) { + auto const point = m_marble_path.current_point(); + auto const& destination_rect = cell_rect.translated( + point.x() * board_cell_dimension.width(), + point.y() * board_cell_dimension.height()); + paint_cell(painter, destination_rect, m_board->selected_marble().color(), m_marble_animation_frame); + } + + // Draw removing marble + if (m_game_state == GameState::MarblesRemoving) + for (auto const& marble : m_board->removed_marbles()) { + auto const& point = marble.position(); + auto const& destination_rect = cell_rect.translated( + point.x() * board_cell_dimension.width(), + point.y() * board_cell_dimension.height()); + paint_cell(painter, destination_rect, marble.color(), m_marble_animation_frame); + } + + // Draw marble move trace + if (m_game_state == GameState::MarbleMoving && m_marble_path.remaining_steps() > 1) { + auto const trace_size = Gfx::IntSize { m_trace_bitmaps.first()->width(), m_trace_bitmaps.first()->height() }; + auto const target_trace_size = Gfx::IntSize { 14, 14 }; + auto const source_rect = Gfx::FloatRect(Gfx::IntPoint {}, trace_size); + for (size_t i = 0; i < m_marble_path.remaining_steps() - 1; ++i) { + auto const& current_step = m_marble_path[i]; + auto const destination_rect = Gfx::IntRect(frame_inner_rect().top_left(), target_trace_size) + .translated( + current_step.x() * board_cell_dimension.width(), + board_vertical_margin + current_step.y() * board_cell_dimension.height()) + .translated( + (board_cell_dimension.width() - target_trace_size.width()) / 2, + (board_cell_dimension.height() - target_trace_size.height()) / 2); + auto get_direction_bitmap_index = [&]() -> size_t { + auto const& previous_step = m_marble_path[i + 1]; + if (previous_step.x() > current_step.x()) + return 3; + if (previous_step.x() < current_step.x()) + return 1; + if (previous_step.y() > current_step.y()) + return 0; + return 2; + }; + painter.draw_scaled_bitmap(destination_rect, *m_trace_bitmaps[get_direction_bitmap_index()], source_rect, + 1.0f, Gfx::Painter::ScalingMode::BilinearBlend); + } + } +} + +void ColorLines::restart_timer(int milliseconds) +{ + stop_timer(); + start_timer(milliseconds); +} + +void ColorLines::set_game_state(GameState state) +{ + m_game_state = state; + switch (state) { + case GameState::StartingGame: + m_marble_path.reset(); + m_board->reset(); + m_score = 0; + m_marble_animation_frame = AnimationFrames::marble_default; + update(); + if (m_board->update_preview_marbles(false)) + set_game_state(GameState::GeneratingMarbles); + else + set_game_state(GameState::GameOver); + break; + case GameState::GeneratingMarbles: + m_board->reset_selection(); + m_marble_animation_frame = AnimationFrames::marble_generating_start; + update(); + if (m_board->ensure_all_preview_marbles_are_on_empty_cells()) + restart_timer(TimerIntervals::generating_marbles); + else + set_game_state(GameState::GameOver); + break; + case GameState::MarblesRemoving: + m_marble_animation_frame = AnimationFrames::marble_removing_start; + update(); + restart_timer(TimerIntervals::removing_marbles); + break; + case GameState::Idle: + m_marble_animation_frame = AnimationFrames::marble_default; + update(); + if (m_board->ensure_all_preview_marbles_are_on_empty_cells() && m_board->has_empty_cells()) + stop_timer(); + else + set_game_state(GameState::GameOver); + break; + case GameState::MarbleSelected: + restart_timer(TimerIntervals::selected_marble); + m_marble_animation_frame = AnimationFrames::marble_default; + update(); + break; + case GameState::CheckingMarbles: + m_marble_animation_frame = AnimationFrames::marble_default; + update(); + if (!m_board->place_preview_marbles_on_board()) + set_game_state(GameState::GameOver); + else if (m_board->check_and_remove_marbles()) + set_game_state(GameState::MarblesRemoving); + else + set_game_state(GameState::Idle); + break; + case GameState::MarbleMoving: + restart_timer(TimerIntervals::moving_marble); + m_board->clear_color_at(m_board->selected_marble().position()); + update(); + break; + case GameState::GameOver: + m_marble_animation_frame = AnimationFrames::marble_default; + update(); + break; + default: + VERIFY_NOT_REACHED(); + } +} diff --git a/Userland/Games/ColorLines/ColorLines.h b/Userland/Games/ColorLines/ColorLines.h new file mode 100644 index 0000000000..0197080720 --- /dev/null +++ b/Userland/Games/ColorLines/ColorLines.h @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca> + * Copyright (c) 2022, the SerenityOS developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "MarblePath.h" +#include <AK/NonnullRefPtr.h> +#include <AK/RefPtr.h> +#include <AK/Vector.h> +#include <LibGUI/Frame.h> +#include <LibGfx/Font/BitmapFont.h> +#include <LibGfx/Forward.h> + +class MarbleBoard; + +class ColorLines : public GUI::Frame { + C_OBJECT(ColorLines); + +public: + virtual ~ColorLines() override = default; + + void reset(); + +private: + enum class GameState { + Idle = 0, // No marble is selected, waiting for marble selection + StartingGame, // Game is starting + GeneratingMarbles, // Three new marbles are being generated + MarbleSelected, // Marble is selected, waiting for the target cell selection + MarbleMoving, // Selected marble is moving to the target cell + MarblesRemoving, // Selected marble has completed the move and some marbles are being removed from the board + CheckingMarbles, // Checking whether marbles on the board form lines of 5 or more marbles + GameOver // Game is over + }; + + ColorLines(StringView app_name); + + virtual void paint_event(GUI::PaintEvent&) override; + virtual void mousedown_event(GUI::MouseEvent&) override; + virtual void timer_event(Core::TimerEvent&) override; + + void set_game_state(GameState state); + void restart_timer(int milliseconds); + + using Point = Gfx::IntPoint; + using BitmapArray = Vector<NonnullRefPtr<Gfx::Bitmap>>; + + StringView const m_app_name; + GameState m_game_state { GameState::Idle }; + NonnullOwnPtr<MarbleBoard> m_board; + BitmapArray const m_marble_bitmaps; + BitmapArray const m_trace_bitmaps; + RefPtr<Gfx::BitmapFont> m_score_font; + MarblePath m_marble_path {}; + int m_marble_animation_frame {}; + unsigned m_score {}; + unsigned m_high_score {}; + + static BitmapArray build_marble_color_bitmaps(); + static BitmapArray build_marble_trace_bitmaps(); + + static constexpr auto marble_pixel_size { 40 }; + static constexpr auto board_vertical_margin { 45 }; + static constexpr auto board_cell_dimension = Gfx::IntSize { 48, 48 }; + static constexpr auto number_of_marble_trace_bitmaps { 4 }; + static constexpr auto tile_color { Color::from_rgb(0xc0c0c0) }; + static constexpr auto text_color { Color::from_rgb(0x00a0ff) }; + + enum AnimationFrames { + marble_default = 0, + marble_at_top = 2, + marble_preview = 18, + marble_generating_start = 21, + marble_generating_end = 17, + marble_removing_start = 7, + marble_removing_end = 16, + number_of_marble_bounce_frames = 7 + }; + + enum TimerIntervals { + generating_marbles = 80, + removing_marbles = 60, + selected_marble = 70, + moving_marble = 28 + }; +}; diff --git a/Userland/Games/ColorLines/HueFilter.h b/Userland/Games/ColorLines/HueFilter.h new file mode 100644 index 0000000000..71c70aa970 --- /dev/null +++ b/Userland/Games/ColorLines/HueFilter.h @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca> + * Copyright (c) 2022, the SerenityOS developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include <AK/Math.h> +#include <LibGfx/Filters/MatrixFilter.h> + +// This filter is similar to LibGfx/Filters/HueRotateFilter.h, however it uses +// a different formula (matrix) for hue rotation. This filter provides brighter +// colors compared to the filter provided in LibGfx. +class HueFilter : public Gfx::MatrixFilter { +public: + HueFilter(float angle_degrees) + : Gfx::MatrixFilter(calculate_hue_rotate_matrix(angle_degrees)) + { + } + + virtual bool amount_handled_in_filter() const override + { + return true; + } + + virtual StringView class_name() const override { return "HueFilter"sv; } + +private: + static FloatMatrix3x3 calculate_hue_rotate_matrix(float angle_degrees) + { + float const angle_rads = angle_degrees * (AK::Pi<float> / 180.0f); + float cos_angle = 0.; + float sin_angle = 0.; + AK::sincos(angle_rads, sin_angle, cos_angle); + return FloatMatrix3x3 { + float(cos_angle + (1.0f - cos_angle) / 3.0f), + float(1.0f / 3.0f * (1.0f - cos_angle) - sqrtf(1.0f / 3.0f) * sin_angle), + float(1.0f / 3.0f * (1.0f - cos_angle) + sqrtf(1.0f / 3.0f) * sin_angle), + + float(1.0f / 3.0f * (1.0f - cos_angle) + sqrtf(1.0f / 3.0f) * sin_angle), + float(cos_angle + 1.0f / 3.0f * (1.0f - cos_angle)), + float(1.0f / 3.0f * (1.0f - cos_angle) - sqrtf(1.0f / 3.0f) * sin_angle), + + float(1.0f / 3.0f * (1.0f - cos_angle) - sqrtf(1.0f / 3.0f) * sin_angle), + float(1.0f / 3.0f * (1.0f - cos_angle) + sqrtf(1.0f / 3.0f) * sin_angle), + float(cos_angle + 1.0f / 3.0f * (1.0f - cos_angle)) + }; + } +}; diff --git a/Userland/Games/ColorLines/Marble.h b/Userland/Games/ColorLines/Marble.h new file mode 100644 index 0000000000..5ec2238ad0 --- /dev/null +++ b/Userland/Games/ColorLines/Marble.h @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca> + * Copyright (c) 2022, the SerenityOS developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include <LibGfx/Point.h> + +class Marble final { +public: + using Point = Gfx::IntPoint; + using Color = u8; + + static constexpr int number_of_colors { 6 }; + static constexpr Color empty_cell = NumericLimits<Color>::max(); + + Marble() = default; + Marble(Point position, Color color) + : m_position { position } + , m_color { color } + { + } + + bool operator==(Marble const& other) const = default; + + [[nodiscard]] constexpr Point position() const { return m_position; } + + [[nodiscard]] constexpr Color color() const { return m_color; } + +private: + Point m_position {}; + Color m_color {}; +}; + +namespace AK { +template<> +struct Traits<Marble> : public GenericTraits<Marble> { + static unsigned hash(Marble const& marble) + { + return Traits<Marble::Point>::hash(marble.position()); + } +}; +} diff --git a/Userland/Games/ColorLines/MarbleBoard.h b/Userland/Games/ColorLines/MarbleBoard.h new file mode 100644 index 0000000000..f5550d69d1 --- /dev/null +++ b/Userland/Games/ColorLines/MarbleBoard.h @@ -0,0 +1,356 @@ +/* + * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca> + * Copyright (c) 2022, the SerenityOS developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "Marble.h" +#include "MarblePath.h" +#include <AK/Array.h> +#include <AK/Function.h> +#include <AK/HashTable.h> +#include <AK/IterationDecision.h> +#include <AK/NumericLimits.h> +#include <AK/Queue.h> +#include <AK/Random.h> +#include <AK/Vector.h> +#include <LibGfx/Point.h> +#include <LibGfx/Size.h> + +class MarbleBoard final { +public: + using Color = Marble::Color; + using Point = Gfx::IntPoint; + using PointArray = Vector<Point>; + using SelectedMarble = Marble; + using PreviewMarble = Marble; + using MarbleArray = Vector<Marble>; + + static constexpr Gfx::IntSize board_size { 9, 9 }; + static constexpr size_t number_of_preview_marbles = 3; + static constexpr Color empty_cell = Marble::empty_cell; + + using PreviewMarbles = Array<PreviewMarble, number_of_preview_marbles>; + + MarbleBoard() + { + reset(); + } + + ~MarbleBoard() = default; + + MarbleBoard(MarbleBoard const&) = delete; + + [[nodiscard]] bool has_empty_cells() const + { + bool result = false; + for_each_cell([&](Point point) { + result = is_empty_cell_at(point); + return result ? IterationDecision::Break : IterationDecision::Continue; + }); + return result; + } + + [[nodiscard]] PointArray get_empty_cells() const + { + PointArray result; + for_each_cell([&](Point point) { + if (is_empty_cell_at(point)) + result.append(point); + return IterationDecision::Continue; + }); + random_shuffle(result); + return result; + } + + void set_preview_marble(size_t i, PreviewMarble const& marble) + { + VERIFY(i < number_of_preview_marbles); + m_preview_marbles[i] = marble; + } + + [[nodiscard]] bool place_preview_marbles_on_board() + { + if (!ensure_all_preview_marbles_are_on_empty_cells()) + return false; + for (auto const& marble : m_preview_marbles) + if (!place_preview_marble_on_board(marble)) + return false; + return true; + } + + [[nodiscard]] bool check_preview_marbles_are_valid() + { + // Check marbles pairwise and also check the board cell under this marble is empty + static_assert(number_of_preview_marbles == 3); + return m_preview_marbles[0].position() != m_preview_marbles[1].position() && m_preview_marbles[0].position() != m_preview_marbles[2].position() + && m_preview_marbles[1].position() != m_preview_marbles[2].position() + && is_empty_cell_at(m_preview_marbles[0].position()) + && is_empty_cell_at(m_preview_marbles[1].position()) + && is_empty_cell_at(m_preview_marbles[2].position()); + } + + [[nodiscard]] bool update_preview_marbles(bool use_current) + { + auto empty_cells = get_empty_cells(); + for (size_t i = 0; i < number_of_preview_marbles; ++i) { + auto marble = m_preview_marbles[i]; + // Check marbles pairwise and also check the board cell under this marble is empty + auto const is_valid_marble = [&]() { + switch (i) { + case 0: + return marble.position() != m_preview_marbles[1].position() && marble.position() != m_preview_marbles[2].position() && is_empty_cell_at(marble.position()); + case 1: + return marble.position() != m_preview_marbles[0].position() && marble.position() != m_preview_marbles[2].position() && is_empty_cell_at(marble.position()); + case 2: + return marble.position() != m_preview_marbles[0].position() && marble.position() != m_preview_marbles[1].position() && is_empty_cell_at(marble.position()); + default: + VERIFY_NOT_REACHED(); + } + }; + if (use_current && is_valid_marble()) { + continue; + } + while (!empty_cells.is_empty()) { + auto const position = empty_cells.take_last(); + Color const new_color = get_random_uniform(Marble::number_of_colors); + marble = Marble { position, new_color }; + if (!is_valid_marble()) + continue; + set_preview_marble(i, marble); + break; + } + if (empty_cells.is_empty()) + return false; + } + return empty_cells.size() > 0; + } + + [[nodiscard]] bool ensure_all_preview_marbles_are_on_empty_cells() + { + if (check_preview_marbles_are_valid()) + return true; + return update_preview_marbles(true); + } + + [[nodiscard]] Color color_at(Point point) const + { + VERIFY(in_bounds(point)); + return m_board[point.y()][point.x()]; + } + + void set_color_at(Point point, Color color) + { + VERIFY(in_bounds(point)); + m_board[point.y()][point.x()] = color; + } + + void clear_color_at(Point point) + { + set_color_at(point, empty_cell); + } + + [[nodiscard]] bool is_empty_cell_at(Point point) const + { + return color_at(point) == empty_cell; + } + + [[nodiscard]] static bool in_bounds(Point point) + { + return point.x() >= 0 && point.x() < board_size.width() && point.y() >= 0 && point.y() < board_size.height(); + } + + [[nodiscard]] bool build_marble_path(Point from, Point to, MarblePath& path) const + { + path.reset(); + + if (from == to || !MarbleBoard::in_bounds(from) || !MarbleBoard::in_bounds(to)) { + return false; + } + + struct Trace { + public: + using Value = u8; + + Trace() { reset(); } + + ~Trace() = default; + + [[nodiscard]] Value operator[](Point point) const + { + return m_map[point.y()][point.x()]; + } + + Value& operator[](Point point) + { + return m_map[point.y()][point.x()]; + } + + void reset() + { + for (size_t y = 0; y < board_size.height(); ++y) + for (size_t x = 0; x < board_size.width(); ++x) + m_map[y][x] = NumericLimits<Value>::max(); + } + + private: + BoardMap m_map; + }; + + Trace trace; + trace[from] = 1; + + Queue<Point> queue; + queue.enqueue(from); + + auto add_path_point = [&](Point point, u8 value) { + if (MarbleBoard::in_bounds(point) && is_empty_cell_at(point) && trace[point] > value) { + trace[point] = value; + queue.enqueue(point); + } + }; + + constexpr Point connected_four_ways[4] = { + { 0, -1 }, // to the top + { 0, 1 }, // to the bottom + { -1, 0 }, // to the left + { 1, 0 } // to the right + }; + + while (!queue.is_empty()) { + auto current = queue.dequeue(); + if (current == to) { + while (current != from) { + path.add_point(current); + for (auto delta : connected_four_ways) + if (auto next = current.translated(delta); MarbleBoard::in_bounds(next) && trace[next] < trace[current]) { + current = next; + break; + } + } + path.add_point(current); + return true; + } + for (auto delta : connected_four_ways) + add_path_point(current.translated(delta), trace[current] + 1); + } + return false; + } + + [[nodiscard]] bool check_and_remove_marbles() + { + m_removed_marbles.clear(); + constexpr Point connected_four_ways[] = { + { -1, 0 }, // to the left + { 0, -1 }, // to the top + { -1, -1 }, // to the top-left + { 1, -1 } // to the top-right + }; + HashTable<Marble, Traits<Marble>> marbles; + for_each_cell([&](Point current_point) { + if (is_empty_cell_at(current_point)) + return IterationDecision::Continue; + auto const color { color_at(current_point) }; + for (auto direction : connected_four_ways) { + size_t marble_count = 0; + for (auto p = current_point; in_bounds(p) && color_at(p) == color; p.translate_by(direction)) + ++marble_count; + if (marble_count >= number_of_marbles_to_remove) + for (auto p = current_point; in_bounds(p) && color_at(p) == color; p.translate_by(direction)) + marbles.set({ p, color }); + } + return IterationDecision::Continue; + }); + m_removed_marbles.ensure_capacity(marbles.size()); + for (auto const& marble : marbles) { + m_removed_marbles.append(marble); + clear_color_at(marble.position()); + } + return !m_removed_marbles.is_empty(); + } + + [[nodiscard]] PreviewMarbles const& preview_marbles() const + { + return m_preview_marbles; + } + + [[nodiscard]] bool has_selected_marble() const + { + return m_selected_marble != nullptr; + } + + [[nodiscard]] SelectedMarble const& selected_marble() const + { + VERIFY(has_selected_marble()); + return *m_selected_marble; + } + + [[nodiscard]] bool select_marble(Point point) + { + if (!is_empty_cell_at(point)) { + m_selected_marble = make<SelectedMarble>(point, color_at(point)); + return true; + } + return false; + } + + void reset_selection() + { + m_selected_marble.clear(); + } + + [[nodiscard]] MarbleArray const& removed_marbles() const + { + return m_removed_marbles; + } + + void reset() + { + reset_selection(); + for (size_t i = 0; i < number_of_preview_marbles; ++i) + m_preview_marbles[i] = { { 0, 0 }, empty_cell }; + m_removed_marbles.clear(); + for_each_cell([&](Point point) { + set_color_at(point, empty_cell); + return IterationDecision::Continue; + }); + } + +private: + static void for_each_cell(Function<IterationDecision(Point)> functor) + { + for (int y = 0; y < board_size.height(); ++y) + for (int x = 0; x < board_size.width(); ++x) + if (functor({ x, y }) == IterationDecision::Break) + return; + } + + [[nodiscard]] bool place_preview_marble_on_board(PreviewMarble const& marble) + { + if (!is_empty_cell_at(marble.position())) + return false; + set_color_at(marble.position(), marble.color()); + return true; + } + + static void random_shuffle(PointArray& points) + { + // Using Fisher–Yates in-place shuffle + if (points.size() > 1) + for (size_t i = points.size() - 1; i > 1; --i) + swap(points[i], points[get_random_uniform(i + 1)]); + } + + static constexpr int number_of_marbles_to_remove { 5 }; + + using Row = Array<Color, board_size.width()>; + using BoardMap = Array<Row, board_size.height()>; + + BoardMap m_board; + PreviewMarbles m_preview_marbles; + MarbleArray m_removed_marbles; + OwnPtr<SelectedMarble> m_selected_marble {}; +}; diff --git a/Userland/Games/ColorLines/MarblePath.h b/Userland/Games/ColorLines/MarblePath.h new file mode 100644 index 0000000000..e6d178e988 --- /dev/null +++ b/Userland/Games/ColorLines/MarblePath.h @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca> + * Copyright (c) 2022, the SerenityOS developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include <AK/Vector.h> +#include <LibGfx/Point.h> + +class MarblePath final { +public: + using Point = Gfx::IntPoint; + + MarblePath() = default; + + void add_point(Point point) + { + m_path.append(point); + } + + [[nodiscard]] bool is_empty() const + { + return m_path.is_empty(); + } + + [[nodiscard]] bool contains(Point point) const + { + return m_path.contains_slow(point); + } + + [[nodiscard]] size_t remaining_steps() const + { + return m_path.size(); + } + + [[nodiscard]] Point current_point() const + { + VERIFY(!m_path.is_empty()); + return m_path.last(); + } + + [[nodiscard]] Point next_point() + { + auto const point = current_point(); + m_path.resize(m_path.size() - 1); + return point; + } + + [[nodiscard]] Point operator[](size_t index) const + { + return m_path[index]; + } + + void reset() + { + m_path.clear(); + } + +private: + Vector<Point> m_path; +}; diff --git a/Userland/Games/ColorLines/main.cpp b/Userland/Games/ColorLines/main.cpp new file mode 100644 index 0000000000..163a39ea21 --- /dev/null +++ b/Userland/Games/ColorLines/main.cpp @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022, Oleg Kosenkov <oleg@kosenkov.ca> + * Copyright (c) 2022, the SerenityOS developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "ColorLines.h" +#include <AK/URL.h> +#include <LibConfig/Client.h> +#include <LibCore/System.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Menubar.h> +#include <LibGUI/Window.h> +#include <LibMain/Main.h> + +ErrorOr<int> serenity_main(Main::Arguments arguments) +{ + TRY(Core::System::pledge("stdio rpath recvfd sendfd unix")); + + auto app = TRY(GUI::Application::try_create(arguments)); + + auto const app_name = "ColorLines"sv; + auto const title = "Color Lines"sv; + auto const man_file = "/usr/share/man/man6/ColorLines.md"sv; + + Config::pledge_domain(app_name); + + TRY(Desktop::Launcher::add_allowed_handler_with_only_specific_urls("/bin/Help", { URL::create_with_file_scheme(man_file) })); + TRY(Desktop::Launcher::seal_allowlist()); + + TRY(Core::System::pledge("stdio rpath recvfd sendfd")); + + TRY(Core::System::unveil("/tmp/session/%sid/portal/launch", "rw")); + TRY(Core::System::unveil("/res", "r")); + TRY(Core::System::unveil(nullptr, nullptr)); + + auto app_icon = TRY(GUI::Icon::try_create_default_icon("app-colorlines"sv)); + + auto window = TRY(GUI::Window::try_create()); + + window->set_double_buffering_enabled(false); + window->set_title(title); + window->resize(436, 481); + window->set_resizable(false); + + auto game = TRY(window->try_set_main_widget<ColorLines>(app_name)); + + auto game_menu = TRY(window->try_add_menu("&Game")); + + TRY(game_menu->try_add_action(GUI::Action::create("&New Game", { Mod_None, Key_F2 }, TRY(Gfx::Bitmap::try_load_from_file("/res/icons/16x16/reload.png"sv)), [&](auto&) { + game->reset(); + }))); + TRY(game_menu->try_add_separator()); + TRY(game_menu->try_add_action(GUI::CommonActions::make_quit_action([](auto&) { + GUI::Application::the()->quit(); + }))); + + auto help_menu = TRY(window->try_add_menu("&Help")); + TRY(help_menu->try_add_action(GUI::CommonActions::make_command_palette_action(window))); + TRY(help_menu->try_add_action(GUI::CommonActions::make_help_action([&man_file](auto&) { + Desktop::Launcher::open(URL::create_with_file_scheme(man_file), "/bin/Help"); + }))); + TRY(help_menu->try_add_action(GUI::CommonActions::make_about_action(title, app_icon, window))); + + window->show(); + + window->set_icon(app_icon.bitmap_for_size(16)); + + return app->exec(); +} |