Microsoft Sudoku: Optimización de aplicaciones de UMPC para tecnología táctil y de trazos

MSDN Magazine, abril de 2006

Publicado: 16 de Mayo de 2006

Stephen Toub
Microsoft Corporation

Resumen: Stephen Toub comenta el Sudoku, el juego del rompecabezas numérico, y explica cómo crear una aplicación para resolver los rompecabezas, crearlos y permitir un juego mejorado para PC ultra móviles y Tablet PC. (60 páginas)

Haga clic aquí para descargar el código de muestra de este artículo.

En esta página

Introducción Introducción
¿Qué es el Sudoku? ¿Qué es el Sudoku?
Infraestructura de la aplicación Infraestructura de la aplicación
Detección de plataforma Detección de plataforma
Diseño de aplicaciones para PC ultra móviles Diseño de aplicaciones para PC ultra móviles
Seguridad de acceso del código Seguridad de acceso del código
Control de excepciones graves Control de excepciones graves
Aplicaciones de instancia única Aplicaciones de instancia única
Algoritmos de Sudoku Algoritmos de Sudoku
Mantenimiento del estado del rompecabezas Mantenimiento del estado del rompecabezas
Análisis y resolución de rompecabezas Análisis y resolución de rompecabezas
Generación de rompecabezas Generación de rompecabezas
Interacciones de hardware y Windows Forms Interacciones de hardware y Windows Forms
Diseño de Sudoku Diseño de Sudoku
Dibujo de Sudoku Dibujo de Sudoku
Habilitación de la interacción con Tablet PC Habilitación de la interacción con Tablet PC
Reconocimiento de números creados a partir de varios trazos Reconocimiento de números creados a partir de varios trazos
Compatibilidad con la función de deshacer Compatibilidad con la función de deshacer
Compatibilidad con la función de bloc de notas Compatibilidad con la función de bloc de notas
Compatibilidad con la función de borrar Compatibilidad con la función de borrar
Animación de partida ganada Animación de partida ganada
Almacenamiento de estado y configuración Almacenamiento de estado y configuración
Información sobre la carga de la batería Información sobre la carga de la batería
Ampliación de Sudoku Ampliación de Sudoku
Ampliación de la función de deshacer Ampliación de la función de deshacer
Impresión de rompecabezas Impresión de rompecabezas
Compatibilidad con sistemas de múltiples procesadores Compatibilidad con sistemas de múltiples procesadores
Cómo copiar el rompecabezas en el portapapeles Cómo copiar el rompecabezas en el portapapeles
Además... Además...
Agradecimientos Agradecimientos
Biografía Biografía

Introducción

El año pasado, en una pequeña cafetería de West Village, en Nueva York, vi por primera vez un rompecabezas de lápiz y papel que tenía completamente cautivado a mi amigo Luke. Una conversación animada con nuestras parejas evitó que cayera en la trampa, aunque por poco tiempo. Unos meses más tarde, en julio de 2005, me encontré de nuevo con un Sudoku, esta vez en Londres, mientras visitaba a mi hermano y a un ex-compañero de habitación de la universidad. Esta vez, el juego estaba por todas partes y no había escapatoria posible. Estaba cautivado, enganchado y casi obsesionado. Tal y como les sucede a muchos desarrolladores, con el tiempo las pasiones pasan a convertirse en proyectos y, por eso, sólo necesité el viaje de vuelta a Estados Unidos para crear la primera versión de mi implementación de Sudoku en C#.

Pero no estaba satisfecho... faltaba algo. Había algo apasionante en el hecho de anotar en el papel los números descubiertos mediante la lógica con mi bolígrafo o lápiz, y esta cualidad se perdía al convertir el juego a Windows Forms. Entonces tuve una idea, el Sudoku es la aplicación ideal para Tablet PC. Al día siguiente, descargué el kit de desarrollo de software (SDK) para Tablet PC en mi PC y transformé rápidamente este juego creado para teclado y mouse (ratón) en un juego que se maneja completamente con lápiz.

Figura 1. Microsoft Sudoku

En este artículo se examina detalladamente mi implementación de Sudoku para Tablet PC. Se trata de la misma implementación que realicé para Touch Pack, un paquete de software preinstalado en PC ultra móviles (UMPC). Aquí se describen los aspectos algorítmicos de la implementación del juego Sudoku y los detalles específicos que le ayudarán a implementar otras aplicaciones diseñadas para Tablet PC.

¿Qué es el Sudoku?

El Sudoku es un rompecabezas matemático que actualmente tiene una gran popularidad en Japón y Europa. El juego es cada vez más popular en Estados Unidos y llena los estantes de las librerías de barrio, además de aparecer diariamente en todos los diarios del país. El rompecabezas prototípico es una cuadrícula de 9x9, dividida en nueve cuadros de 3x3, cada uno de los cuales está compuesto por 9 celdas. La figura 2 muestra la cuadrícula completa de 81 celdas

Figura 2. Cuadrícula de Sudoku vacía

El objetivo de este rompecabezas es rellenar cada una de las celdas con un número del 1 al 9, de forma que cada fila, columna y cuadro contenga una instancia de cada número del 1 al 9. Puede crearse un rompecabezas más difícil, también conocido como Super Sudoku, utilizando una cuadrícula de 16x16 con números del 1 al 9 y letras de la A a la G.

La figura 3 muestra una solución válida de un Sudoku

Figura 3. Solución válida de un Sudoku

Se ha calculado que existen más de 1021 soluciones válidas para el Sudoku de 9x9. Para obtener más información sobre estos cálculos, consulte la página http://www.afjarvis.staff.shef.ac.uk/sudoku/ (en inglés). Lo interesante del juego es que el estado inicial del rompecabezas contiene algunos números en varias celdas de la cuadrícula. A partir de dicho estado inicial, existe una, y sólo una, solución posible para el rompecabezas, que puede deducirse rellenando todos los números que faltan utilizando para ello la lógica.

Observe el rompecabezas de la figura 4 (se han resaltado algunas celdas en amarillo y rojo para la explicación). Como ya he mencionado, cada cuadro debe incluir los números del 1 al 9. En este caso, el cuadro superior izquierdo no contiene un 2, pero puedo saber por simple deducción dónde debe ir el 2 en dicho cuadro. Sé que no puede ser en la fila superior porque ya hay un 2 en esa fila (el 2 en la esquina superior derecha, resaltado en amarillo). Sé que tampoco puede ser en la fila central, porque también hay un 2 (el 2 de la segunda fila, cuarta columna). Obviamente, no puede ser en la tercera fila, segunda columna, puesto que ya hay un 7 (señalado en amarillo) y tampoco en la celda situada a la derecha del 7, porque ya hay un 2 en esa columna (señalado en amarillo en la octava fila). Por lo tanto, el 2 debe estar en la tercera fila, primera columna (celda señalada en rojo). Puedo poner el 2 en esa celda y, de este modo, avanzar en la resolución del rompecabezas.

Figura 4. Un Sudoku en el estado inicial

Una vez familiarizados con el juego, el resto de este artículo tratará sobre la implementación de Sudoku para Tablet PC. Para empezar, voy a detallar algunos de los requisitos básicos de las aplicaciones para Tablet PC y mostraré cómo resuelvo los requisitos del Sudoku y cómo puede hacer usted lo mismo con sus propias aplicaciones. Más adelante, describiré los aspectos específicos de la implementación de Sudoku, centrándome en los algoritmos que se pueden utilizar para llevar a cabo muchas de las tareas relacionadas con el rompecabezas, como su creación y resolución. A continuación, explicaré los detalles relativos a Tablet PC; en particular, la utilización de las API de Tablet PC para permitir la interacción con el lápiz. Y, para terminar, compartiré algunas ideas y ejemplos a partir de los que puede desarrollar divertidos proyectos adicionales para ampliar el juego a su gusto.

Infraestructura de la aplicación

Los conceptos de esta sección están dirigidos a las aplicaciones para Tablet PC en general y no específicamente al juego Sudoku.

Detección de plataforma

La mayor parte de los programas escritos para aprovechar la funcionalidad de Tablet PC funcionan mejor sólo en un equipo Tablet PC o en un equipo de desarrollo adecuadamente configurado. Dicho equipo de desarrollo debe tener instalados tanto Microsoft Windows XP Tablet PC Edition Software Development Kit 1.7, como Microsoft Windows XP Tablet PC Edition 2005 Recognizer Pack, que pueden encontrarse en Mobile PC Developer Center (en inglés). Esto a menudo se debe a la utilización del ensamblado Microsoft.Ink.dll, que se distribuye con Windows XP Tablet PC Edition 2005. Por este motivo, es importante que en todas las aplicaciones de Tablet PC se determine si las bibliotecas y funcionalidades necesarias están disponibles antes de intentar ejecutar el código en el que se basa su existencia. Esto sucede aunque se haya creado la aplicación para que cargue y utilice las bibliotecas sólo si las encuentra, ya que se debe utilizar la detección de plataforma para comprobar que existen.

Nota

No es necesario buscar los binarios de Tablet PC, siempre que tpcman17.msm y mstpcrt.msm se redistribuyan. También puede asegurarse de implementar la aplicación únicamente en equipos que ejecuten Windows XP Tablet PC Edition 2005. No obstante, si no le es posible redistribuir los binarios de Tablet PC o si implementa la aplicación en un equipo que no disponga de binarios de Tablet PC, deberá realizar estas comprobaciones para garantizar el correcto funcionamiento de la aplicación. Esto es importante si, por ejemplo, la aplicación contiene un control de tinta y se implementa en un sitio Web.

En el código de ejemplo, todo el código de detección de plataforma se encuentra en la clase PlatformDetection del archivo PlatformDetection.cs, ubicado en la carpeta Utilities. Esta clase contiene un código que permite efectuar varias comprobaciones distintas y exponer los resultados de estas consultas al resto de la aplicación.

En primer lugar, justo después de iniciarse, la aplicación deberá comprobar si el ensamblado Microsoft.Ink.dll está disponible. Esta comprobación debería realizarse de forma implícita cuando el código que hace referencia a las clases del ensamblado Microsoft.Ink.dll se compile por primera vez, pero no se debe permitir que suceda esto, ya que puede resultar complicado recuperarse correctamente de las excepciones que genera el compilador JIT cuando son inesperadas. En su lugar, se puede efectuar una búsqueda explícita mediante los métodos Assembly.Load y Assembly.LoadWithPartialName, para determinar si hay un ensamblado Microsoft.Ink.dll válido.

public static bool InkAssemblyAvailable
{
  get { return _inkAssemblyAvailable; }
}

private static bool _inkAssemblyAvailable =
  (LoadInkAssembly() != null);

private static Assembly LoadInkAssembly()
{
  try
  {
    Assembly a = Assembly.Load(
      "Microsoft.Ink, Version=1.7.2600.2180, " +
      "Culture=neutral, PublicKeyToken=31bf3856ad364e35");
    if (a == null)
    {
      a = Assembly.LoadWithPartialName(
        "Microsoft.Ink, PublicKeyToken=31bf3856ad364e35");
      if (a != null & a.GetName().Version <
        new Version("1.7.2600.2180")) a = null;
    }
    return a;
  }
  catch(IOException){}
  catch(SecurityException){}
  catch(BadImageFormatException){}
  return null;
}

La propiedad pública estática InkAssemblyAvailable devuelve el valor almacenado en un valor booleano privado estático que se inicializa con el valor devuelto de una llamada a LoadInkAssembly. LoadInkAssembly utiliza las API de reflexión para cargar el ensamblado Microsoft.Ink utilizando su nombre completo, incluido el número de versión, la referencia cultural y el símbolo de clave pública. Si el cargador no puede encontrar el ensamblado a partir de esta información, entonces LoadInkAssembly tratará de cargar el ensamblado Microsoft.Ink utilizando un nombre parcial, lo que significa que se omitirá parte de la información del nombre completo del ensamblado durante la búsqueda. En este caso, he omitido el número de versión. La idea es que la implementación de Sudoku pueda utilizarse con una versión más reciente de Microsoft.Ink.dll (por ejemplo, si el juego ejecuta la versión de las API de Tablet PC en Windows Vista) y, al no precisar el número de versión, autorizo al cargador a buscar cualquier versión del ensamblado. Por supuesto, es peligroso suponer que cualquier versión que se encuentre sea apropiada, el juego puede ejecutarse en una versión más antigua que no disponga de la funcionalidad necesaria y, en tal caso, puede producirse un error cuando el compilador JIT trate de compilar el uso de los elementos que faltan. Para contrarrestar este problema, parto de una hipótesis más segura, aunque no al 100%, según la cual, cualquier versión más reciente es aceptable. Por lo tanto, tras cargar el ensamblado por nombre parcial, compruebo el número de versión para asegurarme de que se trata de la versión utilizada para la compilación o de una versión posterior.

Otra opción, bastante mejor, y que es la que utilizo realmente, consiste en dejar que el CLR encuentre y cargue un ensamblado Microsoft.Ink.dll apropiado mediante su lógica estándar y que, a continuación, administre las opciones disponibles en caso de que el ensamblado no esté disponible y no pueda cargarse. Ya he mencionado anteriormente que recuperarse de las excepciones inesperadas generadas al cargar ensamblados y tipos resulta a menudo difícil. Pero cuando eres consciente de que pueden producirse y se les puede sacar partido en un entorno controlado, confiar en las posibilidades de CLR es, a menudo, el mejor enfoque. Puedo cambiar mi implementación y reescribir el método LoadInkAssembly de la siguiente manera:

private static Assembly LoadInkAssembly()
{
  try { return LoadInkAssemblyInternal(); }
  catch(TypeLoadException) {}
  catch(IOException){}
  catch(SecurityException){}
  catch(BadImageFormatException){}
  return null;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static Assembly LoadInkAssemblyInternal()
{
  return typeof(InkOverlay).Assembly;
}

En el método Main del programa (archivo Program.cs), utilizo la propiedad InkAssemblyAvailable para determinar si debo autorizar que continúe la ejecución. A continuación, Main muestra un cuadro de mensaje y se cierra si no se permite que continúe la ejecución.

bool validSystem = PlatformDetection.InkAssemblyAvailable &
          PlatformDetection.RecognizerInstalled;
if (!validSystem)
{
  MessageBox.Show(ResourceHelper.NotRunningOnTablet,
    ResourceHelper.DisplayTitle, MessageBoxButtons.OK,
    MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
  return 1;
}

Tenga en cuenta que no sólo necesito que esté disponible el ensamblado de tinta, sino que también necesito que se instale un reconocedor. En una instalación típica de Tablet PC, la presencia del primero implica la presencia del segundo. No obstante, es posible que se hayan quitado los reconocedores. También es posible que se hayan instalado las API de Tablet PC sin los reconocedores, por ejemplo, en el caso del entorno de desarrollo. Por lo tanto, utilizo el código siguiente para comprobar las dos condiciones:

public static bool RecognizerInstalled
{
  get
  {
    if (!InkAssemblyAvailable) return false;
    return GetDefaultRecognizer() != null;
  }
}

La propiedad RecognizerInstalled determina, en primer lugar, si el ensamblado de tinta está disponible. Esta comprobación es muy importante, ya que mi implementación de GetDefaultRecognizer, que utiliza la propiedad RecognizerInstalled para saber si hay disponible un reconocedor, se basa en tipos de Microsoft.Ink.dll.

[MethodImpl(MethodImplOptions.NoInlining)]
public static Recognizer GetDefaultRecognizer()
{
  Recognizer recognizer = null;
  try
  {
    Recognizers recognizers = new Recognizers();
    if (recognizers.Count > 1)
    {
      try { recognizer = recognizers.GetDefaultRecognizer(); }
      catch {}

      if (recognizer == null)
      {
        try
        {
          recognizer =
            recognizers.GetDefaultRecognizer(1033);
        }
        catch {}
      }
    }
  }
  catch {}
  return recognizer;
}

GetDefaultRecognizer crea una instancia de una colección Microsoft.Ink.Recognizers y garantiza que haya, al menos, un reconocedor disponible. A continuación, utiliza la colección para recuperar el reconocedor predeterminado. Si por alguna razón no pudiera hacerlo, intentará recuperar el reconocedor predeterminado para LCID 1033, que es la configuración regional para el inglés de Estados Unidos (dado que el único reconocimiento que realiza el Sudoku es el de números enteros, esto es suficiente).

Tenga en cuenta que utilicé un atributo System.Runtime.CompilerServices.MethodImplAttribute en GetDefaultRecognizer para indicar que está exento de la inclusión entre líneas. Inclusión entre líneas es un proceso mediante el cual el compilador puede elegir copiar el contenido de una función de destino en un sitio de llamada, en lugar de efectuar la llamada al método real. Cuando se trata de pequeños métodos, cuyo coste de ejecución está determinado por la sobrecarga de una llamada a método, la inclusión entre líneas puede mejorar significativamente su rendimiento. En realidad, debido a la cantidad de código del método GetDefaultRecognizer, es poco probable que el compilador JIT utilice esta opción.

Sin embargo, no quiero la inclusión entre líneas en GetDefaultRecognizer. Recuerde que cuando JIT compila un método, el compilador de JIT intenta cargar todos los ensamblados que contienen los tipos utilizados en ese método. Por ejemplo, cuando JIT compile GetDefaultRecognizer, intentará cargar Microsoft.Ink.dll. Si GetDefaultRecognizer se incluye entre líneas en RecognizerInstalled, el intento de carga tendrá lugar cuando JIT compile RecognizerInstalled, es decir, antes de que RecognizerInstalled tenga la oportunidad de comprobar la existencia del ensamblado de tinta. Por este motivo, utilizo MethodImplAttribute en GetDefaultRecognizer para evitar su inclusión entre líneas. Esto permite a RecognizerInstalled efectuar primero la comprobación de la existencia del ensamblado.

Esta implementación de Sudoku permite utilizar un movimiento de tachado para borrar números especificados anteriormente por el jugador. Desafortunadamente, lo que no se puede ver en la mayoría del código de ejemplo es que la compatibilidad con los movimientos puede generar excepciones si no está instalado un reconocedor de movimiento válido. Por consiguiente, también utilizo PlatformDetection para comprobar la existencia de un reconocedor de movimiento.

public static bool GestureRecognizerInstalled
{
  get
  {
    return InkAssemblyAvailable &
      GestureRecognizerInstalledInternal;
  }
}
private static bool GestureRecognizerInstalledInternal
{
  [MethodImpl(MethodImplOptions.NoInlining)]
  get
  {
    try
    {
      Recognizers recognizers = new Recognizers();
      if (recognizers.Count > 0)
      {
        using(new GestureRecognizer()) return true;
      }
    }
    catch { }
        return false;
  }
}

Tal y como sucede con RecognizerInstalled, la propiedad pública estática GestureRecognizerInstalled no realiza una comprobación explícita. Utiliza, también, un miembro auxiliar marcado como NoInlining, debido a su dependencia del ensamblado Microsoft.Ink. La propiedad GestureRecognizerInstalledInternal determina si existe algún reconocedor. A continuación, crea, de forma explícita, una instancia de un objeto Microsoft.StylusInput.GestureRecognizer e informa de si se creó correctamente. Si no se puede crear una instancia correctamente, se genera una excepción InvalidOperationException (y, por lo tanto, también del bloque try-catch alrededor de la creación de instancias) que indica que el reconocedor solicitado no está disponible con la configuración actual.

La última funcionalidad que expone PlatformDetection se utiliza para determinar si el usuario es diestro o zurdo. El sistema operativo de Tablet PC permite al usuario configurar el sistema dependiendo de si es zurdo o diestro. Las aplicaciones pueden consultar esta configuración y modificar la interfaz de usuario con el fin de adaptarse mejor a la lateralidad del usuario.

public static bool UserIsRightHanded
{
  get
  {
    if (IsRunningOnTablet)
    {
      bool rightHanded = true;
      if (NativeMethods.SystemParametersInfo(
        NativeMethods.SPI_GETMENUDROPALIGNMENT, 0,
          ref rightHanded, 0))
      {
        return rightHanded;
      }
    }
    return true;
  }
}
private static bool IsRunningOnTablet
{
  get
  {
    return NativeMethods.GetSystemMetrics(
      NativeMethods.SM_TABLETPC) != 0;
  }
}

La propiedad comprueba primero si el sistema actual es un equipo Tablet PC. Para ello, no se utiliza InkAssemblyAvailable, ya que la existencia del ensamblado de tinta no implica que el sistema sea un equipo Tablet PC y, por tanto, que se pueda consultar esta configuración. En su lugar, utiliza la función GetSystemMetrics expuesta en user32.dll.

[DllImport("user32.dll", CharSet=CharSet.Auto)]
internal static extern int GetSystemMetrics(int nIndex);

Cuando se le suministra el valor SM_TABLETPC (86), esta función devuelve un valor distinto de cero si el sistema operativo actual es Microsoft Windows XP Tablet PC Edition; si no, devuelve un cero. Cuando sé que la aplicación se ejecuta en Tablet PC, puedo solicitar la configuración de lateralidad mediante la función SystemParametersInfo, expuesta por user32.dll. La preferencia del usuario se expone mediante el valor de configuración SPI_GETMENUDROPALIGNMENT, que determina si los menús emergentes deben alinearse a la izquierda o a la derecha respecto al elemento correspondiente de la barra del menú.

Diseño de aplicaciones para PC ultra móviles

Para los que estamos familiarizados con el desarrollo para Tablet PC, la iniciativa UMPC (PC ultra móvil), recientemente anunciada por Microsoft y un cierto número de fabricantes de equipo original, presenta equipos de tipo dispositivo que constituyen una gran oportunidad. Toda la experiencia que hemos acumulado con el desarrollo de Tablet PC es útil, ya que el UMPC funciona con Windows XP Tablet PC Edition. Esto significa que cada UMPC tiene las API necesarias para desarrollar aplicaciones que se puedan utilizar íntegramente con el lápiz. De hecho, las aplicaciones que se hayan desarrollado para Tablet PC deberían funcionar sin problemas. No obstante, debe tener en cuenta que existen algunas diferencias a la hora de escribir nuevo software para dispositivos UMPC o al optimizar el software actual para ofrecer una mejor experiencia con UMPC.

En primer lugar, un UMPC tiene una pantalla más pequeña, generalmente de 800x480. Esto significa que debe tener presente el espacio en pantalla que necesita la aplicación. Además, si la aplicación está dirigida a Tablet PC estándar además de UMPC, deberá diseñarla de manera que permita un rendimiento óptimo con distintos tamaños de pantalla. Explico cómo solucionar este problema en el Sudoku en la sección Interacciones de hardware y Windows Forms.

Mientras que la mayoría de Tablet PC actuales tienen pantallas electromagnéticas que permiten una detección e interacción sofisticadas mediante el uso de lápices específicos, los UMPC disponen de pantallas táctiles. Esto los hace más accesibles y permite a los usuarios manipular fácilmente un UMPC con el dedo o con cualquier otro dispositivo señalador que tenga a mano. Sin embargo, también significa que al ser la mayoría de pantallas táctiles sensibles también al contacto de la palma de la mano, la experiencia del usuario puede ser distinta al compararla con la de Tablet PC con digitalizador electromagnético. Al diseñar la interfaz de usuario de la aplicación, asegúrese de tener en cuenta esto en el diseño. Mi implementación de Sudoku permite múltiples métodos de interacción. Se pueden especificar fácilmente los números en el rompecabezas a través del teclado o con un lápiz, pero también he incluido botones numéricos relativamente grandes. Esto facilita la utilización de los dedos para puntear en un número y, a continuación, en una celda de la cuadrícula de Sudoku para copiar el número en dicha celda.

Otro elemento que hay que tener en cuenta es que los lápices de los equipos UMPC son simples dispositivos señaladores y no dispositivos electromagnéticos. Esto significa que las aplicaciones no reciben tanta información de las interacciones del lápiz como sucede en un equipo Tablet PC con un digitalizador electromagnético. Asegúrese de que sus aplicaciones para dispositivos UMPC no utilizan información complementaria como, por ejemplo, paquetes en el aire o presión del digitalizador, para que el funcionamiento sea correcto.

Lo más importante, sin embargo, es la posibilidad de aplicar en su totalidad los conocimientos de desarrollo adquiridos al trabajar con Tablet PC. Si tiene en cuenta las consideraciones adicionales a la hora de diseñar y desarrollar para los UMPC, podrá ofrecer a los usuarios una experiencia más satisfactoria. Como puede observarse, he desarrollado el Sudoku para que funcione correctamente tanto en Tablet PC estándar como en los dispositivos UMPC.

Seguridad de acceso del código

A primera vista, uno puede pensar que implementar un juego como el Sudoku no implica tener que pensar en la seguridad, pero nada más lejos de la realidad. Como desarrollador, tienes que pensar siempre en la seguridad y en las medidas de seguridad que se deben imponer a los usuarios.

Por ejemplo, el Sudoku utiliza varios API Win32. El acceso a estos API necesita permisos de código no administrado. Esto no supone ningún problema si el Sudoku se ejecuta en un disco local y la configuración de seguridad de acceso del código (CAS) predeterminada se conserva intacta. Pero, ¿y si alguien intentara ejecutar el Sudoku desde un recurso compartido de la red? De forma predeterminada, no se conceden permisos de código no administrado a las aplicaciones de intranet. Por lo tanto, el sistema generaría excepciones de seguridad si el Sudoku intentara tener acceso a estas API no administradas.

En lugar de intentar ejecutar el Sudoku en un entorno de confianza parcial, decidí que el Sudoku, como la mayoría de las aplicaciones administradas, necesitaría plena confianza. Sin embargo, la experiencia del usuario sería bastante catastrófica si intentara ejecutar el Sudoku a partir de un entorno de confianza parcial, y la aplicación sufriera un error grave ante sus ojos. En su lugar, el Sudoku comprueba explícitamente la plena confianza durante el inicio e informa al usuario si el entorno actual no es compatible. Éste es el inicio del método Main del Sudoku:

[STAThread]
public static int Main()
{
  Application.EnableVisualStyles();
  Application.DoEvents();
  if (!HasFullTrust)
  {
    MessageBox.Show(ResourceHelper.LacksMinimumPermissionsToRun,
      ResourceHelper.DisplayTitle, MessageBoxButtons.OK,
      MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
    return 1;
}

El Sudoku determina si la aplicación dispone de plena confianza. Si no fuera el caso, mostraría al usuario un cuadro de diálogo de error y se cerraría. La propiedad HasFullTrust se implementa de la siguiente manera:

private static bool HasFullTrust
{
  get
  {
    try
    {
      new PermissionSet(PermissionState.Unrestricted).Demand();
      return true;
    }
    catch (SecurityException) { return false; }
  }
}

La propiedad crea una instancia de System.Security.PermissionSet para el acceso no restringido y de plena confianza, y pide este permiso. El método Demand genera una excepción SecurityException si el permiso requerido no está disponible. Como resultado, este código se incluye en un bloque try. Si el método Demand funciona, HasFullTrust devuelve el valor true. Si se genera una excepción, HasFullTrust devuelve el valor false.

Es interesante tener en cuenta que si la comprobación de plena confianza da error, la aplicación intentará mostrar un cuadro de mensaje. Existe un permiso que controla si se pueden mostrar los cuadros de mensaje o no. Si no se ha definido el permiso UIPermission para SafeSubWindows, la aplicación recibirá una excepción de seguridad al intentar mostrar un cuadro de mensaje. Existen varias maneras de hacer esto. Una opción sería interceptar la excepción resultante y cerrar el programa, pero los usuarios, entonces, no sabrían qué habría pasado. Harían doble clic en la aplicación en el Explorador de Microsoft Windows y no ocurriría nada (o, más bien, no verían que sucediese nada. Windows iniciaría la aplicación en segundo plano y, después, saldría del programa sin mostrar ninguna señal visual). En su lugar, escogí marcar el ensamblado como si necesitara este permiso SafeSubWindows como requisito mínimo

[assembly: UIPermission(SecurityAction.RequestMinimum,
  Window=UIPermissionWindow.SafeSubWindows)]

De esta manera, si el ensamblado no dispone, como mínimo, de este permiso, el CLR mostrará su propio cuadro de diálogo mediante el que informará al usuario de que la aplicación no puede cargarse por la ausencia de los permisos necesarios. Si se concede este permiso como mínimo, la aplicación se cargará y mi lógica para comprobar la confianza plena en el método Main la sustituirá.

Control de excepciones graves

El método Main, tras comprobar la confianza plena y asegurarse de que el ensamblado Microsoft.Ink está disponible y los reconocedores están instalados, realiza una llamada a otro método, MainInternal, que realiza todo el trabajo de configuración e inicio de un formulario principal. MainInternal activa el bucle principal de mensajes de la aplicación. Por lo tanto, todas las excepciones no controladas que provienen de interacciones con la interfaz de usuario en el juego se propagan a partir de MainInternal. Para garantizar que estas excepciones se registran correctamente (con fines de diagnóstico y depuración), el método Main las intercepta y registra y, a continuación, sale de la aplicación.

try
{
  return MainInternal() ? 0 : 1;
}
catch(Exception exc)
{
  ShutdownOnException(new UnhandledExceptionEventArgs(exc, false));
  return 1;
}
catch
{
  ShutdownOnException(new UnhandledExceptionEventArgs(null, false));
  return 1;
}

Utilizo el método ShutdownOnException para registrar todas las condiciones de error grave y para garantizar que la aplicación se cierra lo más rápidamente posible.

internal static void ShutdownOnException(UnhandledExceptionEventArgs e)
{
  try
  {
    string message = (e.ExceptionObject != null) ?
      e.ExceptionObject.ToString() :
      ResourceHelper.ShutdownOnError;
    EventLog.WriteEntry(ResourceHelper.DisplayTitle, message,
      EventLogEntryType.Error);
  }
  catch {}

  try
  {
    MessageBox.Show(ResourceHelper.ShutdownOnError,
      ResourceHelper.DisplayTitle, MessageBoxButtons.OK,
      MessageBoxIcon.Hand, MessageBoxDefaultButton.Button1);
  }
  catch {}

  if (!e.IsTerminating) Environment.Exit(1);
}

Todo esto podría haberse realizado más fácilmente con el método System.Environment.FailFast, de .NET Framework 2.0, pero esta implementación se basa en .NET Framework 1.1.

El método ShutdownOnException acepta un parámetro System.UnhandledExceptionEventArgs que describe lo sucedido. El método intenta, en primer lugar, registrar la información de la excepción, especialmente su seguimiento de la pila, en el registro de sucesos de la aplicación mediante el método estático System.Diagnostics.EventLog.WriteEntry. A continuación, intenta mostrar un cuadro de mensaje con el fin de comunicar a los usuarios que ha sucedido algo. Finalmente, si la aplicación no se ha cerrado, el método Environment.Exit interrumpe la ejecución.

Tal y como hemos visto hasta ahora, ShutdownOnException recibe llamadas únicamente por las excepciones no controladas en el subproceso principal de la interfaz del usuario. Pero, ¿qué sucede con las excepciones graves en otros subprocesos? Para explicar esto, lo primero que hace el método MainInternal es asociar controladores de eventos al evento UnhandledException actual de AppDomain y al evento Application.ThreadException de Windows Forms.

AppDomain.CurrentDomain.UnhandledException +=
  new UnhandledExceptionEventHandler(
    CurrentDomain_UnhandledException);
Application.ThreadException +=
  new ThreadExceptionEventHandler(Application_ThreadException);

El controlador de estos eventos delega en ShutdownOnException.

private static void Application_ThreadException(
  object sender, ThreadExceptionEventArgs e)
{
  ShutdownOnException(new UnhandledExceptionEventArgs(
    e.Exception, false));
}
private static void CurrentDomain_UnhandledException(
  object sender, UnhandledExceptionEventArgs e)
{
  ShutdownOnException(e);
}

Una vez establecido este código, se registrarán todos los errores o excepciones graves que se encuentren los usuarios, lo que facilita la depuración de los errores producidos. Tenga en cuenta que con .NET Framework 2.0, la mayoría de estos procesos se realiza de forma automática. Todas las excepciones no controladas ocasionan la interrupción del proceso y Dr. Watson interviene para registrar la información de la aplicación para su posterior análisis y depuración.

Aplicaciones de instancia única

Muchas aplicaciones, en particular las de Tablet PC, son aplicaciones de instancia única. Una aplicación de instancia única es una aplicación en la que sólo un proceso ejecuta la aplicación en un momento dado, ya sea en un escritorio en particular o en todo el equipo, según cómo se defina la "instancia única" y de las necesidades de la aplicación. En el caso del Sudoku, quería que cada usuario del equipo pudiera ejecutar una única instancia de Sudoku a la vez. Si hubiese implementado el Sudoku con Microsoft Visual Studio 2005 y .NET Framework 2.0, dispondría de la compatibilidad con instancia única. Sin embargo, al utilizar .NET Framework 1.1, tuve que implementar mi propia compatibilidad. Para obtener más información sobre compatibilidad con instancia única, consulte la columna .NET Matters del número de septiembre de 2005 de MSDN Magazine (en inglés).

Mi compatibilidad con instancia única se integra en la clase SingleInstance, disponible en el archivo SingleInstance.cs, dentro de la carpeta Utilities. MainInternal utiliza SingleInstance de la siguiente manera:

using(SingleInstance si = new SingleInstance())
{
  if (si.IsFirst)
  {
    // Create the main form and run the app
    ...

    // Finished game successfully
    return true;
  }
  else
  {
    // Not the first Sudoku instance... show the other one
    si.BringFirstToFront();
    return false;
  }
}

En primer lugar, creo una nueva instancia de la clase y pregunto a su propiedad IsFirst para determinar si se están ejecutando otras instancias de la aplicación. Si ésta es la primera instancia, la aplicación continuará de manera normal y creará y mostrará el formulario principal de la aplicación. Si no lo es, el método BringFirstToFront mostrará la ventana de la primera instancia de la aplicación Sudoku en primer plano y cerrará la segunda instancia

La funcionalidad de instancia única necesita una forma de comunicación entre procesos (IPC), ya que una instancia debe poder indicar a otra que ya existe una y que la carga de la segunda instancia no debe continuar. La clase SingleInstance utiliza dos mecanismos de IPC diferentes: una primitiva de sincronización entre procesos y un archivo asignado en memoria.

La creación de una funcionalidad básica de instancia única es simple y puede efectuarse con sólo unas líneas de código.

static void Main()
{
  string mutexName = "2ea7167d-99da-4820-a931-7570660c6a30";
  bool grantedOwnership;
  using(Mutex singleInstanceMutex =
    new Mutex(true, mutexName, out grantedOwnership)
  {
    if (grantedOwnership)
    {
      ... // core code here
    }
  }
}

Cuando dos o más subprocesos deben obtener acceso simultáneamente a un recurso compartido, necesitan un mecanismo de sincronización para garantizar que sólo uno de los subprocesos tiene acceso al recurso en un momento determinado. En nuestro caso, ese "recurso" es más lógico que físico. Se trata de la capacidad de tener una instancia de la aplicación en ejecución. Una exclusión mutua (mutex), implementada en la clase System.Threading.Mutex, es una primitiva de sincronización que concede el acceso exclusivo a un solo proceso en un momento determinado. Si un subproceso obtiene una Mutex, otro subproceso que quiera obtener la misma Mutex quedará suspendido hasta que el primero libere la Mutex. Otra solución, aparte de suspender el subproceso mientras espera el recurso, sería que un subproceso intentara obtener la Mutex y se informase de que dicha Mutex ya está ocupada con otro subproceso.

Aquí, una Mutex se crea con un GUID como nombre (cada aplicación que utilice este código tendrá su propio nombre único). Cuando se crea una Mutex con un nombre, puede utilizarse para sincronizar subprocesos de procesos distintos, en los que cada proceso se refiere a la Mutex en función de su nombre predeterminado y conocido. Uno de los constructores Mutex acepta no sólo el nombre, sino también un parámetro booleano out que indica si puede obtenerse realmente la Mutex. Esto facilita en gran medida la creación de una instancia única. El código crea una Mutex y determina si puede obtenerse. Si es así, esta instancia de la aplicación será la primera instancia y podrá continuar. Si no se pudiera obtener la Mutex, será una segunda instancia y deberá cerrarse.

Esencialmente, SingleInstance se basa en los mismos principios, pero es mucho más completo. En primer lugar, en vez de utilizar un GUID codificado de forma rígida, SingleInstance crea un Id. basado en la identidad del ensamblado de entrada en la aplicación actual.

private static string ProgramID
{
  get
  {
    Assembly asm = Assembly.GetEntryAssembly();
    Guid asmId = Marshal.GetTypeLibGuidForAssembly(asm);
    return asmId.ToString("N") + asm.GetName().Version.ToString(2);
  }
}

El Id. es una combinación del GUID de la biblioteca de tipos del ensamblado y de la versión del ensamblado. A continuación, este Id. se utiliza de la siguiente manera para crear el nombre de la Mutex:

string mutexName = "Local\\" + ProgramID + "_Lock";

Igual que en el ejemplo de instancia única anterior, mi constructor de clase SingleInstance crea una instancia de Mutex, aunque lo almacena en una variable miembro en lugar de hacerlo en una variable local.

_singleInstanceLock = new Mutex(true, mutexName, out _isFirst);

El tercer parámetro del constructor es la variable booleana que indica si esta instancia de Mutex pudo obtener el bloqueo, y es éste el valor booleano al que se puede tener acceso mediante la propiedad IsFirst.

public bool IsFirst { get { return _isFirst; } }

La implementación de IDisposable.Dispose mediante SingleInstance cierra la Mutex.

Si ésta fuera toda la funcionalidad requerida, ya habría terminado. No obstante, muchas aplicaciones de instancia única también sitúan la instancia existente en primer plano al intentar iniciar una segunda instancia. Esa funcionalidad necesita la transmisión de información adicional entre los procesos. En particular, además de detectar la existencia de otra instancia, una segunda instancia debería poder determinar qué proceso debe ponerse en primer plano. He visto hacer esto a algunas aplicaciones buscando un nombre específico dentro de todas las ventanas de alto nivel del escritorio, pero este proceso no es demasiado estable. Otro enfoque, que es el que yo utilicé, consiste en utilizar un archivo asignado en memoria.

Para nuestro escenario, piense en los archivos asignados en memoria como una forma de memoria compartida. Esto permite a un proceso escribir información en memoria que otro proceso puede leer más tarde. Concretamente, la primera instancia de la aplicación puede escribir su Id. de proceso en un archivo asignado en memoria para que, posteriormente, instancias secundarias puedan leer ese Id. de proceso y utilizarlo para poner la instancia principal en primer plano.

private void WriteIDToMemoryMappedFile(uint id)
{
  _memMappedFile = new HandleRef(this,
    NativeMethods.CreateFileMapping(new IntPtr(-1), IntPtr.Zero,
      PAGE_READWRITE, 0, IntPtr.Size, _memMapName));
  if (_memMappedFile.Handle != IntPtr.Zero &
    _memMappedFile.Handle != new IntPtr(-1))
  {
    IntPtr mappedView = NativeMethods.MapViewOfFile(
      _memMappedFile.Handle, FILE_MAP_WRITE, 0, 0, 0);
    try
    {
      if (mappedView != IntPtr.Zero)
        Marshal.WriteInt32(mappedView, (int)id);
    }
    finally
    {
      if (mappedView != IntPtr.Zero)
        NativeMethods.UnmapViewOfFile(mappedView);
    }
  }
}

private uint ReadIDFromMemoryMappedFile()
{
  IntPtr fileMapping = NativeMethods.OpenFileMapping(
    FILE_MAP_READ, false, _memMapName);
  if (fileMapping != IntPtr.Zero & fileMapping != new IntPtr(-1))
  {
    try
    {
      IntPtr mappedView = NativeMethods.MapViewOfFile(
        fileMapping, FILE_MAP_READ, 0, 0, 0);
      try
      {
        if (mappedView != IntPtr.Zero)
          return (uint)Marshal.ReadInt32(mappedView);
      }
      finally
      {
        if (mappedView != IntPtr.Zero)
          NativeMethods.UnmapViewOfFile(mappedView);
      }
    }
    finally { NativeMethods.CloseHandle(fileMapping); }
  }
  return 0;
}

Tal y como sucedía con las exclusiones mutuas entre procesos, se puede atribuir un nombre a los archivos asignados en memoria para que varios procesos puedan tener acceso a ellos por el nombre. Un archivo asignado en memoria se crea mediante la función CreateFileMapping expuesta en kernel32.dll. Al archivo asignado en memoria se le asigna el espacio de dirección actual con la función MapViewOfFile, que también está expuesta en kernel32.dll. MapViewOfFile proporciona la dirección inicial de la memoria, que se puede utilizar con los métodos de la clase System.Runtime.InteropServices.Marshal para leer y escribir los datos en esta memoria. Si ya se ha creado un archivo asignado en memoria, se puede utilizar la función OpenFileMapping de kernel32.dll para abrirlo.

[DllImport("Kernel32", CharSet=CharSet.Auto, SetLastError=true)]
internal static extern IntPtr CreateFileMapping(IntPtr hFile,
  IntPtr lpAttributes, int flProtect, int dwMaxSizeHi,
  int dwMaxSizeLow, string lpName);

[DllImport("Kernel32", CharSet=CharSet.Auto, SetLastError=true)]
internal static extern IntPtr OpenFileMapping(int dwDesiredAccess,
  [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle,
  string lpName);

[DllImport("Kernel32", CharSet=CharSet.Auto, SetLastError=true)]
internal static extern IntPtr MapViewOfFile(IntPtr hFileMapping,
  int dwDesiredAccess, int dwFileOffsetHigh, int dwFileOffsetLow,
  int dwNumberOfBytesToMap);

[DllImport("Kernel32", CharSet=CharSet.Auto, SetLastError=true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool UnmapViewOfFile(IntPtr pvBaseAddress);

[DllImport("kernel32.dll", SetLastError=true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool CloseHandle(IntPtr handle);

El constructor de SingleInstance crea la Mutex y determina si es el primero en hacerlo. Si es así, el constructor SingleInstance utilizará el método WriteIDToMemoryMappedFile para almacenar su propio Id. de proceso para su posterior recuperación por parte de las instancias secundarias. Cuando una instancia secundaria se ejecuta y realiza una llamada al método BringFirstToFront, utiliza ReadIDFromMemoryMappedFile para tener acceso al Id. del proceso de la instancia principal. Este Id. de proceso puede, entonces, utilizarse con las funciones ShowWindowAsync y SetForegroundWindow expuestas en user32.dll para situar la instancia principal en primer plano.

public void BringFirstToFront(){ if (!_isFirst) { uint processID = ReadIDFromMemoryMappedFile(); if (processID != 0) { const int SW_SHOWNORMAL = 0x1; const int SW_SHOW = 0x5; IntPtr hwnd = new ProcessInfo(processID).MainWindowHandle; if (hwnd != IntPtr.Zero) { int swMode = NativeMethods.IsIconic(new HandleRef( this, hwnd)) ? SW_SHOWNORMAL : SW_SHOW; NativeMethods.ShowWindowAsync(hwnd, swMode); NativeMethods.SetForegroundWindow(new HandleRef( this, hwnd)); } } }}

Algoritmos de Sudoku

En esta sección se describen los aspectos de la aplicación específicos del juego Sudoku

Mantenimiento del estado del rompecabezas

Cuando planeo la creación de una aplicación como ésta, comienzo por crear un conjunto mínimo de funciones operativas que luego pueda cambiar. En el caso del Sudoku, esto implica la creación de las estructuras de datos necesarias para almacenar el estado del juego.

El rompecabezas se puede representar fácilmente con una matriz de dos dimensiones. Cada celda debe poder almacenar un número o quedarse vacía. .NET Framework 2.0 ofrece una solución interesante en forma de tipos que aceptan valores null. Los tipos que aceptan valores en .NET Framework 2.0 permiten a los tipos de valor tomar un valor null, y se representan con una nueva estructura genérica, Nullable<T>. Internamente, Nullable<T> almacena dos valores: uno de tipo T (donde T es el parámetro de tipo genérico y puede ser cualquier tipo de valor, excepto otro tipo que acepte valores null) en el que se almacena el valor de la instancia, y uno booleano que indica si la instancia posee un valor o no. Los valores genéricos no están disponibles en .NET Framework 1.1. Sin embargo, esto no significa que no tenga suerte. Para los tipos para los que necesito compatibilidad con los valores null, creo mis propios reemplazos, tales como NullableByte y NullableInt. Cada uno de estos reemplazos es una estructura que contiene un valor del tipo relevante (como byte para NullableByte o int para NullableInt), y un valor booleano que indica si el valor entero contiene un valor que se pueda utilizar. Esto no es tan elegante como la solución genérica de .NET Framework 2.0, especialmente desde que C# 2.0 ofrece una compatibilidad sintáctica con Nullable<T>, pero para el caso que nos ocupa, es suficiente. Puesto que cada celda de la cuadrícula del rompecabezas puede estar vacía o contener un pequeño valor numérico, guardo la cuadrícula como una matriz de NullableByte.

[Serializable]
public sealed class PuzzleState : ICloneable, IEnumerable
{
  private NullableByte[,] _grid;
  private readonly byte _boxSize;
  private readonly byte _gridSize;
  ...
}

La clase PuzzleState contiene varios datos, incluido el tamaño de la cuadrícula y su contenido. En la implementación actual, sólo se admiten las cuadrículas de Sudoku de 9x9, pero con un poco de dedicación, la aplicación podría ampliarse para poder admitir cuadrículas más grandes, incluso cuadrículas no cuadradas.

Uno de los aspectos más importantes de PuzzleState es su implementación de la interfaz ICloneable. Esta implementación, con la que se puede generar fácilmente una copia en profundidad de una instancia PuzzleState, se utiliza intensamente en otros aspectos de esta solución, como en la resolución y generación de rompecabezas, o incluso en las funciones relacionadas con el juego, como la función de deshacer.

object ICloneable.Clone() { return this.Clone(); }
public PuzzleState Clone() { return new PuzzleState(this); }
private PuzzleState(PuzzleState state)
{
  _boxSize = state._boxSize;
  _gridSize = state._gridSize;
  _grid = (NullableByte[,])state._grid.Clone();
  ...
}

Análisis y resolución de rompecabezas

Resolver un rompecabezas Sudoku es en realidad un tipo de búsqueda especial y, como tal, es posible aprovechar los algoritmos de búsqueda estándar. Uno de los algoritmos de búsqueda más comunes es el de búsqueda en profundidad (DFS), que generalmente es uno de los primeros que se enseñan en una clase de estructuras de datos y algoritmos en la facultad. Durante una búsqueda, existen muchos caminos que explorar y, en una búsqueda en profundidad, uno de estos caminos se explora íntegramente, incluyendo todos los caminos resultantes, antes de continuar con la exploración de los demás caminos. Aunque se puede implementar una búsqueda en profundidad utilizando una pila explícita, se suele realizar mediante recursividad. Imagine la estructura de datos de un nodo del árbol, que almacena un valor además de una lista de nodos secundarios. Una búsqueda en profundidad de un valor determinado en un árbol presentará el siguiente aspecto:

public class TreeNode<T> where T : IComparable
{
  public TreeNode(T value) { Value = value; }
  public T Value;
  public List<TreeNode<T> Children = new List<TreeNode<T>();

  public TreeNode<T> DfsSearch(T v)
  {
    if (Value.CompareTo(v) == 0) return this;
    foreach (TreeNode<T> child in Children)
    {
      TreeNode<T> rv = child.DfsSearch(v);
      if (rv != null) return rv;
    }
    return null;
  }
}

Al llamara a DfsSearch desde TreeNode<T> se comprueba si el valor almacenado en el nodo es el mismo que se busca. Si es así, se devuelve el nodo actual. Si no, se realiza una llamada al método DfsSearch de cada nodo secundario para determinar si el valor buscado se encuentra dentro del subárbol del nodo secundario. De esta manera, se puede buscar en toda la estructura del árbol.

Es posible buscar la solución de un Sudoku de la misma manera. Imaginemos que el estado particular de un rompecabezas es el nodo de un árbol. Desde este punto, es posible obtener acceso a otros estados "secundarios" especificando un número en una celda vacía de la cuadrícula. Por lo tanto, a partir de cada estado, se puede alcanzar un número finito de estados secundarios (un estado por cada valor que pueda especificar en cada celda vacía). Para una cuadrícula de 9x9, existe un máximo de 729 estados secundarios, es decir, un número finito. Por lo tanto, es posible implementar la búsqueda de una solución de cualquier rompecabezas Sudoku con sólo una pequeña cantidad de código DFS, como se indica a continuación:

static PuzzleState DfsSolve(PuzzleState state)
{
  switch (CheckSolution(state))
  {
    case PuzzleStatus.Solved: return state;
    case PuzzleStatus.CannotBeSolved: return null;
    default:
      NullablePoint cell = FindFirstEmptyCell(state);
      if (cell == null) return null;
      for (byte n = 0; n < state.GridSize; n++)
      {
        state[cell.Value] = n;
        PuzzleState result = DfsSolve(state);
        if (result != null) return result;
      }
      state[cell.Value] = null;
      return null;
  }
}

Este método, parecido al empleado por mi implementación de Sudoku, examina el PuzzleState y determina si se ha resuelto, si actualmente se encuentra en un estado no válido (lo que significa que existe un conflicto en algún punto) y no puede resolverse definitivamente, o si se trata de un rompecabezas todavía no resuelto. Esta comprobación es similar a la búsqueda DfsSearch del TreeNode<T>, que determina si se ha encontrado un nodo con el valor correcto, y en este momento compruebo si he encontrado una solución. Si he encontrado una solución, la devuelvo. Si he encontrado un estado no válido, significa que he cometido un error en alguna parte y debo volver atrás. Si el rompecabezas está en curso, debo especificar un número en una celda vacía. Encuentro la primera celda vacía y pruebo con todos los valores posibles, uno tras otro. Si el rompecabezas tiene solución, uno de los valores deberá funcionar. Escribo un número en cada celda y, a continuación, llamo de forma recursiva a DfsSolve. Al final, si se puede resolver el rompecabezas, se encontrará la solución.

Desafortunadamente, este enfoque presenta un gran inconveniente: requiere muchísimo tiempo para completarse. Para una cuadrícula de 9x9, son necesarios hasta nueve valores por cada celda. Con hasta 81 celdas por rellenar, se podría terminar comprobando 981 cuadrículas. Aunque cada nanosegundo se procesara un número en cada celda, es muy poco probable que vivamos para ver terminar el proceso. De hecho, el sol ya habría dejado de brillar antes de poder encontrar una solución. El verdadero problema es que busco en todo el espacio de búsqueda posible, aunque se puedan eliminar muchos elementos del árbol de búsqueda, por ejemplo, limitar la cantidad de exploración que debe efectuar la búsqueda. Cuando hacemos un Sudoku, sabemos que existen muchos movimientos que no son posibles, así pues, ¿por qué debería comprobarlos el equipo? Eliminémoslos.

Existen diferentes técnicas para indicar al equipo qué puede eliminar, pero una de las más sencillas consiste en indicarle los números que definitivamente no pueden encontrarse en una celda. Así, cuando el equipo efectúe la búsqueda por la fuerza bruta, sólo tendrá que tener en cuenta los números posibles.

Se debe generar una matriz de bits para cada celda de la cuadrícula. La idea es poder excluir ciertos números para cada celda mediante el examen de los demás números definidos automáticamente dentro de la columna, fila y casilla de esta celda. Los números que aparezcan en uno de estos espacios no pueden aparecer en la celda. En principio, una matriz de bits es un conjunto de conmutadores (encendido/apagado) que mantiene valores booleanos de un conjunto de elementos. La forma más sencilla de representar una matriz de bits en .NET Framework es mediante la clase System.Collections.BitArray. No obstante, tras utilizar esta clase en la implementación y realizar algunas comprobaciones de generación de perfiles, descubrí que sería interesante crear una nueva implementación basada en mis necesidades específicas. Así que creé mi propia implementación, FastBitArray, ubicada en el archivo FastBitArray.cs del directorio Collections (si alguna vez ha escrito un depósito de bits, la implementación le resultará familiar). Puesto que esta implementación de Sudoku nunca debe tratar con valores elevados (ya que sólo admite cuadrículas de 9x9), FastBitArray incluye un único entero sin signo, que le permite contener hasta 32 valores booleanos. Las operaciones de escritura y lectura se implementan con una simple manipulación de bits.

public bool Get(int index)
{
  return (_bits & (1u < index)) != 0;
}
public void Set(int index, bool value)
{
  bool curVal = Get(index);
  if (value & !curVal)
  {
    _bits |= (1u < index);
    _countSet++;
  }
  else if (!value & curVal)
  {
    _bits &= ~(1u < index);
    _countSet--;
  }
}

Mantengo, a continuación, una matriz bidimensional de instancias FastBitArray, correspondiente a la matriz bidimensional que contiene los valores de cada celda de la cuadrícula.

El conocimiento de las celdas posibles acelera el funcionamiento del método DfsSolve. En lugar de intentar cada valor en cada celda, puedo limitar la búsqueda a sólo los posibles valores candidatos de cada celda. Además, la implementación DfsSolve anterior busca y rellena la primera celda en blanco encontrada, que constituye un heurístico insuficiente. Puesto que sólo uno de los valores que puedo probar en una celda en particular es el correcto, tiene sentido recortar el árbol comenzando por la celda que presente la menor cantidad de números que son candidatos. Por lo tanto, para mejorar DfsSolve se puede buscar la celda vacía con el menor número de candidatos posibles entre todas las celdas en blanco. Cada vez que se define una celda en DfsSolve, puedo actualizar la matriz de números candidatos posibles, eliminando el número que se acaba de definir como posibilidad de cada celda afectada.

Aunque creé DfsSolve con fines explicativos, la implementación que utilizo en el Sudoku es muy similar. Éstas son las versiones un poco reducidas de los dos métodos principales de mi clase Solver, disponibles en el archivo Solver.cs. Fíjese en las similitudes con DfsSolve y en las sugerencias de ampliación descritas anteriormente.

private static SolverResults SolveInternal(
  PuzzleState state, SolverOptions options)
{
  state = state.Clone();

  FastBitArray [][] possibleNumbers =
    FillCellsWithSolePossibleNumber(state,
    options.EliminationTechniques);

  switch (state.Status)
  {
    case PuzzleStatus.Solved:
    case PuzzleStatus.CannotBeSolved:
      return new SolverResults(state.Status, state, 0);
    default:
      if (options.AllowBruteForce)
      {
        SolverResults results = BruteForceSolve(
          state, options, possibleNumbers);
        return results;
      }
      else return new SolverResults(
        PuzzleStatus.CannotBeSolved, state, 0, null);
  }
}

private static SolverResults BruteForceSolve(PuzzleState state,
  SolverOptions options, FastBitArray [][] possibleNumbers)
{
  // Find cells with fewest possible candidates
  ArrayList bestGuessCells = new ArrayList();
  int bestNumberOfPossibilities = state.GridSize + 1;
  for (int i = 0; i < state.GridSize; i++)
  {
    for (int j = 0; j < state.GridSize; j++)
    {
      int count = possibleNumbers[i][j].CountSet;
      if (!state[i, j].HasValue)
      {
        if (count < bestNumberOfPossibilities)
        {
          bestNumberOfPossibilities = count;
          bestGuessCells.Clear();
          bestGuessCells.Add(new Point(i, j));
        }
        else if (count == bestNumberOfPossibilities)
        {
          bestGuessCells.Add(new Point(i, j));
        }
      }
    }
  }

  // Pick one
  SolverResults results = null;
  if (bestGuessCells.Count > 0)
  {
    Point bestGuessCell = (Point)bestGuessCells[
      RandomHelper.GetRandomNumber(bestGuessCells.Count)];
    FastBitArray possibleNumbersForBestCell = possibleNumbers[
      bestGuessCell.X][bestGuessCell.Y];
    for(byte p=0; p<possibleNumbersForBestCell.Length; p++)
    {
      if (possibleNumbersForBestCell[p])
      {
        PuzzleState newState = state;
        newState[bestGuessCell] = p;
        SolverOptions tempOptions = options.Clone();
        if (results != null)
        {
          tempOptions.MaximumSolutionsToFind = (uint)(
            tempOptions.MaximumSolutionsToFind.Value –
            results.Puzzles.Count);
        }

        // Recur
        SolverResults tempResults =
          SolveInternal(newState, tempOptions);

        if (tempResults.Status == PuzzleStatus.Solved)
        {
          if (results != null & results.Puzzles.Count > 0)
          {
            results.Puzzles.AddRange(tempResults.Puzzles);
          }
          else
          {
            results = tempResults;
            results.NumberOfDecisionPoints++;
          }
          if (options.MaximumSolutionsToFind.HasValue &
            results.Puzzles.Count >=
              options.MaximumSolutionsToFind.Value)
            return results;
        }

        newState[bestGuessCell] = null;
      }
    }
  }
  return results != null ? results : new
    SolverResults(PuzzleStatus.CannotBeSolved, state, 0, null);
}

Suceden muchas cosas en este código. El método SolveInternal comienza creando una copia de PuzzleState y trabaja sobre esta copia en lugar de trabajar sobre el original. De esta manera, el objeto PuzzleState que proporciona el autor de la llamada no se ve afectado por las acciones iniciadas por Solver. A continuación, utiliza FillCellsWithSolePossibleNumber para analizar el tablero de juego actual en busca de las celdas que puedan tener sólo un número candidato posible y las rellena (después volveremos sobre este punto). En este momento, si el tablero de juego se resuelve o parece imposible de resolver, el método vuelve atrás. En caso contrario, utiliza la búsqueda por fuerza bruta para rellenar una celda. La siguiente celda que rellenar se selecciona en base a la celda que tiene la menor cantidad de candidatos posibles (si hubiera varias celdas, se seleccionaría una de manera aleatoria). Una vez se haya rellenado la celda, el sistema, de forma recursiva, llama a SolveInternal y el proceso comienza de nuevo, pero con una celda rellenada más

Observará que una instancia de la clase SolverOptions toma ciertas decisiones durante la operación de resolución. El siguiente código muestra la interfaz pública SolverOptions:

public sealed class SolverOptions : ICloneable
{
  public SolverOptions();
  public NullableUint MaximumSolutionsToFind { get; set; }
  public TechniqueCollection EliminationTechniques { get; }
  public bool AllowBruteForce { get; set; }
  public SolverOptions Clone();
}

MaximumSolutionsToFind permite a un cliente de Solver especificar el número de soluciones que debe intentar buscar; el método sólo se detiene cuando ha encontrado el número de soluciones solicitado o cuando determina que no se puede alcanzar el número solicitado. Observe que MaximumSolutionsToFind es uno de los tipos NullableUint personalizados; si es null, Solver encuentra tantas soluciones como sea posible. Aunque, el valor más corriente suele ser 1 o 2. Al buscar una única solución, puedo determinar rápidamente si un rompecabezas tiene solución. Al buscar dos soluciones, puedo determinar si un rompecabezas tiene una y sólo una solución (si Solver encontrara dos, aunque existieran más, sabré que hay más de una solución posible). La propiedad AllowBruteForce determina si se autoriza la búsqueda por fuerza bruta en Solver o bien si se debe rellenar un rompecabezas utilizando únicamente el método FillCellsWithSolePossibleNumber. FillCellsWithSolePossibleNumber utiliza la propiedad EliminationTechniques para determinar los números posibles para cada celda. (Comentaré AllowBruteForce y FillCellsWithSolePossibleNumber en la sección Generación de rompecabezas.)

Al comentar el prototipo DfsSolve, pasé por alto CheckSolution. En mi implementación real de Sudoku, PuzzleState.Status sustituye a CheckSolution, una propiedad que analiza el estado actual del rompecabezas y devuelve un valor que indica si está resuelto, si no puede resolverse o si se encuentra en una fase intermedia.

public PuzzleStatus Status
{
  get
  {
    if (_status == PuzzleStatus.Unknown)
      _status = AnalyzeSolutionStatus();
    return _status;
  }
}
private PuzzleStatus AnalyzeSolutionStatus()
{
  FastBitArray numbersUsed = new FastBitArray(_gridSize);

  // Make sure no column has duplicates
  for (int i = 0; i < _gridSize; i++)
  {
    numbersUsed.SetAll(false);
    for (int j = 0; j < _gridSize; j++)
    {
      if (_grid[i, j].HasValue)
      {
        int value = _grid[i, j].Value;
        if (numbersUsed[value])
          return PuzzleStatus.CannotBeSolved;
        numbersUsed[value] = true;
      }
    }
  }

  // Same for rows
  for (int j = 0; j < _gridSize; j++)
  {
    numbersUsed.SetAll(false);
    for (int i = 0; i < _gridSize; i++)
    {
      if (_grid[i, j].HasValue)
      {
        int value = _grid[i, j].Value;
        if (numbersUsed[value])
          return PuzzleStatus.CannotBeSolved;
        numbersUsed[value] = true;
      }
    }
  }

  // Same for boxes
  for (int boxNumber = 0; boxNumber < _gridSize; boxNumber++)
  {
    numbersUsed.SetAll(false);
    int boxStartX = (boxNumber / _boxSize) * _boxSize;
    for (int x = boxStartX; x < boxStartX + _boxSize; x++)
    {
      int boxStartY = (boxNumber % _boxSize) * _boxSize;
      for (int y = boxStartY; y < boxStartY + _boxSize; y++)
      {
        if (_grid[x, y].HasValue)
        {
          int value = _grid[x, y].Value;
          if (numbersUsed[value])
            return PuzzleStatus.CannotBeSolved;
          numbersUsed[value] = true;
        }
      }
    }
  }

  // Now figure out whether this is a solved puzzle or a work
  // in progress based on whether there are any holes
  for (int i = 0; i < _gridSize; i++)
  {
    for (int j = 0; j < _gridSize; j++)
    {
      if (!_grid[i, j].HasValue) return PuzzleStatus.InProgress;
    }
  }

  // If I made it this far, this state is a valid solution!
  return PuzzleStatus.Solved;
}

PuzzleState.Status analiza la solución actual en múltiples pasos. Comprueba si ya se ha calculado o almacenado en caché el valor PuzzleStatus del estado. Si es así, PuzzleStatus puede simplemente devolver el valor almacenado en caché (cualquier cambio en el rompecabezas invalidará la memoria caché). Suponiendo que se debe calcular el valor PuzzleStatus del estado, Status utilizará el método AnalyzeSolutionStatus para determinar si hay números duplicados actualmente en una columna, fila o casilla. Si hay duplicados, obviamente el rompecabezas no puede resolverse. Comprueba, a continuación, si hay celdas vacías en la cuadrícula. Si éste es el caso, significaría que el rompecabezas aún está en curso. Si no, significa que ya estaría resuelto. Observe que AnalyzeSolutionStatus no indica si un rompecabezas en curso puede resolverse, sino únicamente si está resuelto, si definitivamente no tiene ninguna solución o si potencialmente tiene una solución. Como ya hemos visto, corresponde a Solver buscar la solución verdadera.

Esto es básicamente Solver. Un método Solve público incluye el método SolveInternal y se deben efectuar operaciones adicionales para realizar el seguimiento de estadísticas sobre cómo se resolvió el rompecabezas, una información muy útil durante la generación del rompecabezas. No representa una gran cantidad de código en comparación con la utilidad y eficacia que resulta de esta clase.

Generación de rompecabezas

Si la generación de rompecabezas aleatorios con una sola solución parece el problema más delicado de resolver, el código es, de hecho, relativamente simple, puesto que todos los elementos ya están creados. Estoy seguro de que hay disponibles muchos algoritmos para generar rompecabezas en Internet. No obstante, he escogido mi algoritmo por diversas razones: es elegante, es fácil de codificar a partir de las clases y métodos implementados hasta ahora, es lo suficientemente rápido y, lo mejor de todo, funciona. Desde luego, es posible quitar el generador del código y utilizar otro propio en su lugar, si se desea.

La idea de este algoritmo es simple y tiene dos partes. Primero, necesito crear una solución de Sudoku completa y aleatoria, es decir, una solución aleatoria en la que las 81 celdas de la cuadrícula estén rellenadas de manera que cada fila, celda y casilla contenga una, y sólo una, instancia de cada número del 1 al 9. A primera vista, puede parecer muy complejo, por lo que puede sorprenderle saber que ya he implementado todo el código necesario para este enfoque: Solver.Solve. Cuando expliqué la resolución de rompecabezas y la búsqueda por fuerza bruta de la siguiente celda, seleccioné una celda al azar de entre las que tenían la menor cantidad de números candidatos posibles. Por lo tanto, para crear una solución de Sudoku aleatoria, me basta con resolver una cuadrícula en blanco. Tómese un momento y reflexione, ya que se trata de una afirmación importante. En el primer paso, Solver rellenará aleatoriamente una de las 81 celdas con un valor, puesto que las 81 celdas tienen la misma cantidad de números candidatos posibles. Tras rellenar esta celda, algunas celdas tendrán menos números candidatos que otras y Solver escogerá una de ellas al azar. De esta manera, se generará una solución aleatoria. Se puede generar una solución válida y aleatoria en una o dos líneas de código.

PuzzleState newPuzzle = Solver.Solve(
  new PuzzleState(), new SolverOptions()).Puzzle;

Así de simple. Puede que, sin duda alguna, haya maneras más eficaces, pero este código necesita apenas unos milisegundos para ejecutarse en mi equipo portátil, que es suficiente para mis propósitos.

En el segundo paso en mi algoritmo, se parte de la solución completa y se quitan de forma continua números de las celdas, seleccionando al azar una celda rellenada de la cuadrícula y borrándola. Tras cada eliminación, utilizo de nuevo Solver para determinar si hay más de una solución posible. Obviamente, sólo hay una solución para el tablero de juego original y, tras quitar un par de números, deberá seguir habiendo sólo una solución posible. No obstante, después de borrar un número importante de celdas, llegará un momento en el que se podrá resolver de varias formas, lo que conducirá a soluciones de Sudoku diferentes. Como esto va en contra de nuestro objetivo de crear un tablero de juego con una única solución, se deben evitar estos estados. Por lo tanto, después de cada eliminación, Solver indicará si ya se ha alcanzado un punto en el que el estado es ambiguo. En dicho punto, se puede deshacer la eliminación para conseguir un tablero de juego de inicio válido. A continuación, se muestran las características principales de este enfoque, tal y como se encuentran implementadas en la clase Generator del archivo Generator.cs:

Point [] filledCells = GetRandomCellOrdering(newPuzzle);
int filledCellCount = filledCells.Length;
for(int filledCellNum=0;
  filledCellNum < filledCellCount &
    newPuzzle.NumberOfFilledCells > _options.MinimumFilledCells;
  filledCellNum++)
{
  byte oldValue = newPuzzle[filledCells[filledCellNum]].Value;
  newPuzzle[filledCells[filledCellNum]] = null;
  SolverResults newResults = Solver.Solve(
    newPuzzle, solverOptions);
  if (!IsValidRemoval(newPuzzle, newResults))
    newPuzzle[filledCells[filledCellNum]] = oldValue;
}

El método GetRandomCellOrdering crea una lista de las 81 celdas del rompecabezas en orden aleatorio. El método IsValidRemoval comprueba si la última eliminación rompió alguna regla, especialmente la regla principal según la cual sólo debe haber una solución posible. Como se puede observar en el código, recorro cada una de las 81 celdas en el orden que devuelve GetRandomCellOrdering. Para cada celda, intento quitarla y si el estado resultante no es válido de acuerdo con IsValidRemoval, la vuelvo a poner.

Mi generador implementa lo que se conoce como un algoritmo voraz. Esto significa que el algoritmo efectúa todos los movimientos válidos que encuentre, incluso aunque no realizar un movimiento válido pudiera ofrecer un mejor resultado global. Como sucede con muchos algoritmos voraces, aunque el resultado no sea óptimo, sí que es suficientemente bueno. Además, es difícil definir qué constituye una solución óptima para el Sudoku y se debe tener en cuenta que las soluciones óptimas no son precisamente indispensables. Esto es positivo. La creación de rompecabezas Sudoku corresponde a una categoría de problemas conocida como NP completo. Sencillamente, esto significa que la generación de un rompecabezas Sudoku óptimo puede llevar mucho tiempo. Por consiguiente, son preferibles los heurísticos que proporcionan soluciones "suficientemente buenas".

Tal y como pasó con Solver, he omitido algunas cosas. En primer lugar, puede observar que en las opciones de configuración del juego y en la clase GeneratorOptions, el generador puede generar rompecabezas con una simetría de 180 grados. Esto significa que la celda situada en [x,y] se rellena si la celda en [10-x, 10-y] se rellena (por ejemplo, si se rellena la celda de la primera columna, segunda fila, entonces también se rellena la celda de la novena columna, octava fila, y viceversa). Esto se implementa con un ligero aumento del código ilustrado anteriormente. En primer lugar, si se utiliza simetría, la clasificación devuelta por el método GetRandomCellOrdering se modifica con el fin de crear una clasificación aleatoria de pares de celdas en lugar de una clasificación aleatoria de celdas individuales, de manera que los pares simétricos se encuentren el uno al lado del otro en la clasificación. A continuación, esto permite al código suprimir los valores por pares en vez de individualmente, a efectos de restaurar los valores por pares si una eliminación tiene como resultado un rompecabezas no válido.

El generador admite también niveles de dificultad. Los niveles de dificultad se basan en diversas opciones de configuración, especialmente el número mínimo de celdas que deben rellenarse y las técnicas que un jugador pueda necesitar para resolver un rompecabezas. Estas técnicas nos remiten a Solver y a la propiedad SolverOptions.EliminationTechniques.

Si ha leído algún libro acerca del Sudoku o ha buscado información en Internet, ya sabrá que existen diversas técnicas formales para resolverlos. Yo ya he descrito una técnica, que consiste en eliminar un número como número candidato posible de una celda porque el número ya está en otra celda en la misma fila, columna o casilla. Si examina la carpeta Techniques en la descarga del código fuente, encontrará que he implementado diversas técnicas, especialmente técnicas como subconjunto desnudo, subconjunto oculto y ala-x. Estas técnicas se emplean para definir los números de las celdas de la cuadrícula y quitar los números candidatos posibles de cada celda. En función de la configuración del nivel de dificultad para Generator, se permite a Solver utilizar únicamente determinadas técnicas, ya que algunas son más complejas que otras.

El número de rompecabezas que crea Generator también influye en el nivel de dificultad. En lugar de generar sólo un rompecabezas, para determinados niveles de dificultad Generator crea varios rompecabezas y, a continuación, escoge el que considera más difícil. Generator toma esta decisión basándose en las estadísticas que devuelve Solver relacionadas con las técnicas utilizadas para resolver el rompecabezas y el número de veces que se ha utilizado cada una.

Tal y como se observa en el código fuente, existen tres niveles de dificultad en la implementación actual.

  • En el nivel Easy (Fácil), se generan tres rompecabezas, se deben rellenar al menos 32 celdas, y sólo se necesita una técnica para resolverlo.

  • En el nivel Medium (Medio), se generan 10 rompecabezas, no hay un mínimo de celdas que se deban rellenar y se deben emplear diversas técnicas.

  • En el nivel Difficult (Difícil), se generan 20 rompecabezas, no hay un mínimo de celdas que se deban rellenar y se deben emplear todas las técnicas implementadas. De hecho, el nivel más difícil activa la opción AllowBruteForce para Solver. Esto significa que es posible crear un rompecabezas que no se pueda resolver sólo con las técnicas proporcionadas y que puede incluso necesitar una forma lógica de ensayo y error. Si desaprueba este enfoque, tal y como les sucede a ciertos fans del Sudoku, puede perfectamente cambiar el código.

Interacciones de hardware y Windows Forms

En las siguientes secciones se describen aspectos del juego Sudoku relacionados con las características de Tablet PC y la experiencia del usuario.

Diseño de Sudoku

Un aspecto importante de las aplicaciones para Tablet PC es su representación. ¿Qué aspecto tiene la aplicación en los diferentes tamaños de pantalla de Tablet PC? ¿La aplicación se adapta bien a las diferentes orientaciones de pantalla? ¿Se adapta bien a las altas resoluciones de PPP y a fuentes de tamaño grande? Estas cuestiones son importantes para lograr que una aplicación funcione correctamente. Por supuesto, es complicado comentar estos aspectos de una aplicación sin antes hablar de su construcción.

He implementado el Sudoku como una aplicación de Windows Forms y, a excepción de OptionsDialog, que permite al usuario configurar el juego, toda la aplicación se presenta en un solo formulario, MainForm, disponible en el archivo MainForm.cs. El único control de la colección Control de MainForm es un ImagePanel acoplado para rellenar el formulario y que sirve como fondo de toda la aplicación y como contenedor del siguiente nivel de controles del formulario. ImagePanel es un control sencillo creado con el único propósito de dibujar una imagen para rellenar el control.

internal class ImagePanel : NoFlickerPanel
{
  public ImagePanel(){}

  [DesignerSerializationVisibility(
     DesignerSerializationVisibility.Hidden)]
  [Browsable(false)]
  public Image Image { get { return _img; } set { _img = value; } }
  private Image _img;

  protected override void OnPaint(PaintEventArgs e)
  {
    if (_img != null)
    {
      e.Graphics.DrawImage(_img, 0, 0, Width, Height);
    }
    base.OnPaint(e);
  }
}

ImagePanel se deriva del control NoFlickerPanel en lugar de hacerlo directamente de System.Windows.Forms.Panel.

internal class NoFlickerPanel : Panel
{
  public NoFlickerPanel()
  {
    SetStyle(ControlStyles.DoubleBuffer |
      ControlStyles.AllPaintingInWmPaint |
      ControlStyles.UserPaint | ControlStyles.ResizeRedraw |
      ControlStyles.SupportsTransparentBackColor, true);
  }
}

NoFlickerPanel sirve como base para varias de mis clases especializadas derivadas de Panel. Su único objetivo es habilitar el uso del búfer doble, además de actualizar de forma automática el control cuando se modifica su tamaño. El búfer doble es muy importante para el aspecto de la aplicación, ya que evita que el Panel parpadee cuando se actualiza. Si habilita la actualización automática cuando se modifica el tamaño del formulario, el soporte necesario es más sencillo cuando el usuario cambia el tamaño o la orientación de la pantalla de Tablet PC.

El panel de fondo tiene otros cuatro paneles secundarios:

  • Un panel NoFlickerPanel, que sirve como contenedor para el control de cuadrícula (el control que realmente representa el rompecabezas y permite la interacción del usuario).

  • Dos paneles ImagePanels, que contienen los controles de las "pantallas" que permiten al usuario comenzar una nueva partida o continuar una anterior.

  • Un panel ScalingPanel, derivado de NoFlickerPanel, que controla el diseño completo de los controles empleados para interaccionar con el juego (como el botón de lápiz, los botones numéricos y el botón de salida).

Una de las cosas que quería lograr en esta implementación de Sudoku era que los usuarios tuvieran una experiencia cómoda y les gustara el aspecto del juego. Para lograrlo, quería asegurarme de que cuando el formulario principal cambiase de tamaño, los controles hicieran lo propio, manteniendo sus tamaños y posiciones relativos. ScalingPanel lo hace posible.

internal class ScalingPanel : NoFlickerPanel
{
  public ScalingPanel(){}

  private Rectangle _initialBounds;
  private Hashtable _controlBounds;
  private bool _initialized;

  public void ConfigureByContainedControls()
  {
    _initialBounds = Bounds;
    _controlBounds = new Hashtable();
    foreach(Control c in this.Controls)
    {
      _controlBounds.Add(c, c.Bounds);
    }
    _initialized = true;
  }

  protected override void OnLayout(LayoutEventArgs levent)
  {
    if (_initialized & Width > 0 & Height > 0 &
      levent.AffectedControl == this)
    {
      // Maintain original aspect ratio
      int newWidth = Width;
      int tmp = (int)(_initialBounds.Width /
        (double)_initialBounds.Height * Height);
      if (tmp < newWidth) newWidth = tmp;

      int newHeight = Height;
      tmp = (int)(_initialBounds.Height /
        (double)_initialBounds.Width * newWidth);
      if (tmp < newHeight) newHeight = tmp;

      // Keep track of max and min boundaries
      int minX=int.MaxValue, minY=int.MaxValue, maxX=-1, maxY=-1;

      // Move and resize all controls
      foreach(Control c in this.Controls)
      {
        Rectangle rect = (Rectangle)_controlBounds[c];

        // determine initial best guess at size
        int x = (int)(rect.X / (double)
          _initialBounds.Width * newWidth);
        int y = (int)(rect.Y / (double)
          _initialBounds.Height * newHeight);
        int width = (int)(rect.Width / (double)
          _initialBounds.Width * newWidth);
        int height = (int)(rect.Height / (double)
          _initialBounds.Height * newHeight);

        // set the new bounds
        Rectangle newBounds = new Rectangle(
          x, y, width, height);
        if (newBounds != c.Bounds) c.Bounds = newBounds;

        // Keep track of max and min boundaries
        if (c.Left < minX) minX = c.Left;
        if (c.Top < minY) minY = c.Top;
        if (c.Right > maxX) maxX = c.Right;
        if (c.Bottom > maxY) maxY = c.Bottom;
      }

      // Center all controls
      int moveX = (Width - (maxX - minX + 1)) / 2;
      int moveY = (Height - (maxY - minY + 1)) / 2;

      if (moveX > 0 || moveY > 0)
      {
        foreach(Control c in this.Controls)
        {
          c.Location = c.Location +
            new Size(moveX - minX, moveY - minY);
        }
      }
    }

    // Do base layout
    base.OnLayout (levent);
  }
}

ScalingPanel funciona con la premisa de que los tamaños y posiciones iniciales de los controles, tal como se crearon visualmente en el diseñador de formularios de Visual Studio, son los tamaños y posiciones relativos y deben mantenerse en toda la aplicación. Tras agregarse todos los controles secundarios a una instancia de ScalingPanel, el cliente llama al método ConfigureByContainedControls del control ScalingPanel. Este método inicializa varios componentes de estado que se emplearán más tarde en el diseño del formulario. En primer lugar, almacena los límites actuales del control. A continuación, recorre todos los controles secundarios y almacena sus límites actuales en una tabla System.Collections.Hashtable, indizada por Control. Con el tamaño de ScalingPanel y el tamaño y posición iniciales de cada control individual, ScalingPanel conoce la relación del tamaño de cada control respecto al panel contenedor y también conoce la distancia relativa entre controles. Cuando se cambia el tamaño del panel, ScalingPanel utiliza esta información para variar la posición y el tamaño de cada control y así mantener las relaciones de tamaño y posición actuales.

Toda la magia sucede en el reemplazo de OnLayout. En primer lugar, ScalingPanel comprueba si se ha llamado previamente a ConfigureByContainedControls, lo que evita operaciones de diseño personalizado si no se ha llamado antes. También comprueba que el tamaño del panel sea superior a 0 para no tener que realizar el diseño cuando se minimiza el formulario o si se realizan operaciones similares que pueden causar la reducción del tamaño de una forma igualmente considerable. Finalmente, ScalingPanel garantiza que el control objetivo de la operación del diseño sea el propio panel, para evitar la duplicación innecesaria de operaciones de diseño cuando se requiere que varios controles relacionados con el panel efectúen un diseño.

La operación de diseño requiere tres pasos principales.

  1. Comprueba que la relación de aspecto del cuadro de límite permanezca constante para todos los controles; de este modo, el método calcula el nuevo ancho y alto del cuadro de límite, en función del ancho y alto actuales del panel en relación con sus medidas originales.

  2. Recorre todos los controles ajustando la posición y el tamaño de cada uno, en función de la proporción del cuadro de límite original respecto al nuevo.

  3. Centra el cuadro de controles dentro del panel. De este modo se garantiza que si la relación de alto y ancho del panel es muy diferente a la original, los controles permanezcan centrados dentro del panel.

Esta funcionalidad de ScalingPanel no sirve únicamente para mantener el buen aspecto del formulario cuando el usuario cambia su tamaño. Como todos los cálculos se basan en tamaños y posiciones relativos, al mismo tiempo se consigue compatibilidad con una alta resolución de PPP (empleada frecuentemente en los equipos Tablet PC). La incompatibilidad con altas resoluciones de PPP supone un error común en muchas aplicaciones; esto se debe a que muchos de los desarrolladores y personal de pruebas que trabajan en aplicaciones que inicialmente no se diseñaron para ejecutarse en Tablet PC desconocen que se puede cambiar la configuración de PPP en Windows. La implementación de controles como ScalingPanel puede hacer que su vida como desarrollador o personal de pruebas sea mucho más fácil.

Por supuesto, la funcionalidad de ScalingPanel sólo es útil cuando se modifica el tamaño del propio panel y si se hace de una forma relativa al tamaño actual del formulario. Como ya he mencionado antes, el formulario principal de Sudoku tiene cuatro controles de paneles secundarios, pero sólo dos de ellos están acoplados al panel. El panel de controles está acoplado a la derecha del formulario y el panel que contiene el control de cuadrícula está acoplado para rellenar la parte restante. Esto permite mantener más fácilmente los tamaños relativos de ambos paneles cuando se cambia el tamaño del formulario principal. Me aseguro de que el panel de control siempre ocupe un tercio del ancho del formulario, lo que permite que el panel de cuadrícula ocupe el resto.

protected override void OnLayout(LayoutEventArgs levent)
{
  pnlControls.Width = Width / 3;
  base.OnLayout(levent);
}

Para entender cómo afecta esto al tablero de juego, observe la captura de pantalla de la figura 5. Si se reduce el ancho del formulario principal, el resultado es el diseño de la figura 6, mientras que si se aumenta, el resultado es el diseño de la figura 7.

Figura 5. Diseño predeterminado

Figura 6. Diseño tras contraer el ancho

Figura 7. Diseño tras aumentar el ancho

Este enfoque también se adapta fácilmente a la lateralidad manual del usuario. Mi método SetHandedness se llama al iniciarse la aplicación y cada vez que los usuarios cambian la configuración de lateralidad de Windows.

private void SetHandedness()
{
  DockStyle targetStyle = PlatformDetection.UserIsRightHanded ?
    DockStyle.Right : DockStyle.Left;
  if (targetStyle != pnlControls.Dock)
  {
    pnlControls.SendToBack();
    pnlControls.Dock = targetStyle;
  }
}

Es un método muy sencillo y efectivo. Con tan sólo unas líneas de código, comprueba la configuración de lateralidad actual del usuario y en función de ella, cambia el acoplamiento del panel de controles a la derecha o a la izquierda del formulario principal. Y eso es todo. La compatibilidad con el diseño que acabo de describir se encarga de lo demás y el juego responde inmediatamente a cualquier cambio en las preferencias del usuario (explico cuándo se llama realmente a este método en Información sobre la carga de la batería).

Las figuras 8 y 9 muestran el cambio de un formulario en el que se ha modificado la configuración de destreza manual.

Figura 8. Diseño con los controles a la derecha

Figura 9. Diseño con los controles a la izquierda

Ya he explicado cómo cambian de tamaño los controles del panel de controles y los dos paneles principales uno respecto del otro. El resto del diseño automático se controla mediante el reemplazo de OnResize en MainForm.

protected override void OnResize(EventArgs e)
{
  // Do the base resizing
  base.OnResize(e);

  // Put the new puzzle and saved puzzle panels in the middle
  pnlNewPuzzle.Location = new Point(
    backgroundPanel.Location.X +
      (backgroundPanel.Width - pnlNewPuzzle.Width) / 2,
    backgroundPanel.Location.Y +
      (backgroundPanel.Height - pnlNewPuzzle.Height) / 2);
  pnlSavedOrNewPuzzle.Location = new Point(
    backgroundPanel.Location.X +
      (backgroundPanel.Width - pnlSavedOrNewPuzzle.Width) / 2,
    backgroundPanel.Location.Y +
      (backgroundPanel.Height - pnlSavedOrNewPuzzle.Height) / 2);

  // Make sure when I resize the form that the puzzle grid
  // stays in the center and square, as big as it can be while
  // not exceeding its parent's bounds
  int width = pnlGrid.Width;
  int height = pnlGrid.Height;
  int margin = 25;
  Size gridSize = width > height ?
    new Size(height - margin, height - margin) :
    new Size(width - margin, width - margin);
  thePuzzleGrid.Bounds = new Rectangle(
    new Point((width - gridSize.Width)/2,
    (height - gridSize.Height)/2),
    gridSize);

  // Make sure no matter how the form resizes that the
  // marquee progress bar ends up in the center of the form.
  marqueeBar.Location = new Point(
    backgroundPanel.Location.X +
      (backgroundPanel.Width - marqueeBar.Width) / 2,
    backgroundPanel.Location.Y +
      (backgroundPanel.Height - marqueeBar.Height) / 2);
}

Este método realiza varias operaciones sencillas de diseño. En primer lugar, centra los paneles pnlNewPuzzle y pnlSavedOrNewPuzzle en el formulario (la mayor parte del tiempo estos paneles permanecen ocultos y sólo se muestran al inicio y cuando el usuario elige empezar una nueva partida). También cambia el tamaño y la posición del control de cuadrícula del rompecabezas dentro del panel de cuadrícula (posiblemente esto podría haberse logrado con sólo convertir el panel de cuadrícula en ScalingPanel). Finalmente, se asegura de que la barra de progreso, que muestra el proceso de creación del rompecabezas, aparezca centrada en el formulario.

La barra de progreso ha supuesto un desafío interesante. Windows Forms 1.1 muestra una barra de progreso pero no es compatible de fábrica con el estilo de marquesina. Sin embargo, Microsoft Windows XP sí que es compatible. (El estilo de marquesina representa una tarea sin un punto de inicio y fin determinados. La barra de progreso que aparece al iniciarse Windows es una barra de progreso de estilo de marquesina.) El control ProgressBar de Windows Forms es un contenedor de las barras de progreso comunes de Windows XP, pero no expone la configuración del estilo de marquesina (Windows Forms 2.0 sí que lo hace). Por este motivo, en lugar de utilizar el control ProgressBar de Windows Forms, creé mi propio contenedor para utilizar el estilo de marquesina, tal como se muestra en la figura 10 (evidentemente, cuando se actualiza a Windows Vista, el aspecto mejora, como se puede ver en la figura 11).

internal sealed class MarqueeProgressBar : Control
{
  public MarqueeProgressBar()
  {
    SetStyle(ControlStyles.SupportsTransparentBackColor, true);
    SetStyle(ControlStyles.Selectable |
      ControlStyles.UserPaint, false);
    BackColor = Color.Transparent;
    ForeColor = SystemColors.Highlight;
  }

  protected override CreateParams CreateParams
  {
    get
    {
      CreateParams cp = base.CreateParams;
      cp.ClassName = "msctls_progress32";
      if (!DesignMode) cp.Style |= PBS_MARQUEE;
      if (RightToLeft == RightToLeft.Yes)
      {
        cp.ExStyle |= WS_EX_LAYOUTRTL;
        cp.ExStyle &= ~(WS_EX_RIGHT | WS_EX_RTLREADING |
                        WS_EX_LEFTSCROLLBAR);
      }
      return cp;
    }
  }

  protected override void OnForeColorChanged(EventArgs e)
  {
    base.OnForeColorChanged(e);
    if (base.IsHandleCreated)
    {
      SendMessage(PBM_SETBARCOLOR, 0,
        ColorTranslator.ToWin32(this.ForeColor));
    }
  }

  protected override void OnBackColorChanged(EventArgs e)
  {
    base.OnBackColorChanged(e);
    if (base.IsHandleCreated)
    {
      SendMessage(PBM_SETBKCOLOR, 0,
        ColorTranslator.ToWin32(this.BackColor));
    }
  }

  protected override ImeMode DefaultImeMode
  {
    get { return ImeMode.Disable; }
  }

  protected override Size DefaultSize
  {
    get { return new Size(100, 23); }
  }

  protected override void CreateHandle()
  {
    if (!RecreatingHandle)
    {
      NativeMethods.INITCOMMONCONTROLSEX icc =
        new NativeMethods.INITCOMMONCONTROLSEX();
      icc.dwSize = 0;
      icc.dwICC = ICC_PROGRESS_CLASS;
      NativeMethods.InitCommonControlsEx(icc);
    }
    base.CreateHandle();
  }

  private IntPtr SendMessage(int msg, int wparam, int lparam)
  {
    return NativeMethods.SendMessage(new HandleRef(this,
      this.Handle), msg, new IntPtr(wparam), new IntPtr(lparam));
  }

  protected override void OnHandleCreated(EventArgs e)
  {
    base.OnHandleCreated(e);
    SendMessage(PBM_SETRANGE, MINIMUM, MAXIMUM);
    SendMessage(PBM_SETSTEP, STEP, 0);
    SendMessage(PBM_SETPOS, VALUE, 0);
    SendMessage(PBM_SETBKCOLOR, 0,
      ColorTranslator.ToWin32(BackColor));
    SendMessage(PBM_SETBARCOLOR, 0,
      ColorTranslator.ToWin32(ForeColor));
    Start(DEFAULTSPEED);
  }

  private void Start(int speed)
  {
    if (!DesignMode & IsHandleCreated)
      SendMessage(PBM_SETMARQUEE, speed == 0 ? 0 : 1, speed);
  }

  ... // constant definitions

}

Figura 10. Barra de progreso de marquesina en Windows XP

Figura 11. Barra de progreso de marquesina en Windows Vista

Dibujo de Sudoku

El control PuzzleGrid, ubicado en el archivo PuzzleGrid.cs de la carpeta Controls, es el control más importante de la aplicación. Es el control que muestra el rompecabezas Sudoku, proporciona la compatibilidad básica con el mouse y el teclado para jugar al Sudoku y permite las interacciones del lápiz con el juego.

Tal y como se recomienda, todos los dibujos relacionados con el control se producen en respuesta a un mensaje WM_PAINT. En Windows Forms, normalmente esto conlleva agregar un controlador de eventos para el evento Paint del control o el reemplazo del método OnPaint. Me decidí por la segunda opción.

protected override void OnPaint(PaintEventArgs e)
{
  base.OnPaint(e);
  e.Graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
  DrawToGraphics(e.Graphics, e.ClipRectangle);
}

La lógica de dibujo se incluye en un método DrawToGraphics independiente que acepta el objeto Graphics objetivo y el rectángulo que define los límites invalidados dentro del control. (Volver a dibujar únicamente lo necesario mejora de forma significativa el rendimiento de una aplicación; cuando aumenté mi implementación inicial con esta capacidad, la mejora fue inmediata). Cuando se crea el código de dibujo de esta manera, la compatibilidad de impresión se simplifica enormemente, ya que se trata de la capacidad de representar un control en un objeto Bitmap. Muestro esto en una Animación de partida ganada, donde aprovecho esta capacidad para mostrar una animación de felicitación cuando un jugador resuelve un rompecabezas.

Aunque es bastante largo, el código para dibujar el tablero es muy fácil. Comienza dibujando las imágenes de fondo, ubicadas en la carpeta Images en la descarga de código.

graphics.DrawImage(ResourceHelper.BoardBackgroundImage,
  0, 0, Width, Height);
graphics.DrawImage(ResourceHelper.BoardImage, rect);

La primera imagen se dibuja para rellenar el control. La segunda imagen se dibuja en base al rectángulo devuelto por la propiedad privada BoardRectangle, que devuelve un rectángulo ligeramente más pequeño que el control, lo que permite unos finos márgenes en los cuatro lados. Después, el tamaño y la posición de cada celda se determina utilizando un método privado GetCellRectangle, que tiene en cuenta el tamaño conocido de cada imagen de celda de la imagen de fondo en relación con el tamaño de la imagen completa, además de los tamaños y posiciones de los espacios de la imagen de fondo relativos al tamaño de toda la imagen. Utilizo el tamaño que devuelve GetCellRectangle para determinar si una celda particular debe examinarse o bien dibujarse más detalladamente.

for (int i = 0; i < State.GridSize; i++)
{
  for (int j = 0; j < State.GridSize; j++)
  {
    RectangleF cellRect = GetCellRectangle(rect, new Point(i,j));
    if (clipRectangle.IntersectsWith(Rectangle.Ceiling(cellRect)))
    {
      ... // cell drawn here
    }
  }
}

Del mismo modo, utilizo la información de clipRectangle que proporciona OnPaint para DrawToGraphics y así evitar la representación de celdas que no fueron invalidadas. Puesto que la operación de dibujar es relativamente costosa y frecuentemente se produce una invalidación sólo para regiones pequeñas del control (como cuando un número se coloca en una celda o cambia la celda seleccionada); esto ha mejorado notablemente la facilidad de uso del control.

Cuando decidí que debería dibujar una celda, sucedieron varias cosas. En primer lugar, asumiendo que el rompecabezas no se ha resuelto todavía, se comprueba la celda para determinar si está seleccionada. Si lo está, utilizo una de las cinco imágenes verdes (en la carpeta Images) para pintar la celda. Estas cinco imágenes corresponden a si la celda seleccionada es una de las cuatro esquinas o no.

Image selectedCellImage;
if (i == 0 & j == 0)
  selectedCellImage = ResourceHelper.CellActiveUpperLeft;
else if (i == 0 & j == State.GridSize-1)
  selectedCellImage = ResourceHelper.CellActiveUpperRight;
else if (i == State.GridSize-1 & j == 0)
  selectedCellImage = ResourceHelper.CellActiveLowerLeft;
else if (i == State.GridSize-1 & j == State.GridSize-1)
  selectedCellImage = ResourceHelper.CellActiveLowerRight;
else
  selectedCellImage = ResourceHelper.CellActiveSquare;
graphics.DrawImage(selectedCellImage,
cellRect.X, cellRect.Y, cellRect.Width, cellRect.Height);

Se emplea un método similar para pintar la imagen de celda sugerida sobre una celda, cuando el usuario ha solicitado pistas para la próxima celda que debe rellenar y si la celda que se está representando actualmente es la celda sugerida. La celda sugerida se determina mediante las mismas técnicas empleadas por Solver y Generator para establecer el nivel de dificultad del rompecabezas y las probabilidades de resolución. La propiedad privada SuggestedCell calcula la celda sugerida en base a estas técnicas y guarda en la memoria caché el resultado para que no tenga que volver a calcularse cada vez que se dibuja la cuadrícula (la caché se invalida cuando se realiza un cambio al estado de juego). En este punto, SuggestedCell se implementa de la siguiente forma.

TechniqueCollection tc = new TechniqueCollection();
FastBitArray [][] possibleNumbers =
  PuzzleState.InstantiatePossibleNumbersArray(State);
foreach(EliminationTechnique et in
  EliminationTechnique.AvailableTechniques)
{
  tc.Add(et);
  Hashtable ignored = null;
  State.ComputePossibleNumbers(tc, ref ignored, true,
    true, possibleNumbers);
  for(int row=0; row<State.GridSize; row++)
  {
    for(int column=0; column<State.GridSize; column++)
    {
      if (possibleNumbers[row][column].CountSet == 1)
      {
        return _suggestedCell = new Point(row, column);
      }
    }
  }
}

La lógica recupera todas las técnicas disponibles, en orden de menor a mayor dificultad (determinadas sin excesivo rigor, por mi estimación del nivel de dificultad de la técnica). Comienza por la más sencilla y determina si ejecutando dicha técnica hay alguna celda vacía en la cuadrícula que sólo tiene un número candidato posible. Si existe alguna, la primera en aparecer se devuelve como celda sugerida. Si no se encuentra ninguna, se agrega la siguiente técnica y vuelven a calcularse los números candidatos posibles. De nuevo, si se encuentra alguna celda que sólo tenga un número candidato posible, se devuelve la primera encontrada. Y así sucesivamente hasta que se encuentre una celda sugerida o se agoten las técnicas sin encontrar ninguna (que sólo puede suceder en el nivel de mayor dificultad, donde se permite la técnica de fuerza bruta durante la generación del rompecabezas). En este caso, la celda con menos números candidatos posibles se devuelve como celda sugerida.

Cuando se ha dibujado el fondo de una celda, si ya se ha especificado un número, se representa dicho número.

if (State[i, j].HasValue)
{
  Brush b;
  if (ShowIncorrectNumbers &
    State[i, j].HasValue & _solvedOriginalState != null &
    State[i, j].Value != _solvedOriginalState[i, j].Value)
  {
    b = _incorrectValueBrush;
  }
  else if (_originalState != null & _originalState[i,j].HasValue)
  {
    b = _originalValueBrush;
  }
  else b = _userValueBrush;

  graphics.DrawString((State[i, j] + 1).ToString(
    CultureInfo.InvariantCulture), setNumberFont, b,
    cellRect, _centerNumberFormat);
}

El pincel que se va a utilizar junto con DrawString se elige de una selección de tres pinceles almacenados en la caché:

  • El pincel rojo muestra los valores incorrectos.

  • El pincel negro muestra los valores que ya formaban parte del rompecabezas original.

  • El pincel azul muestra los valores de entrada.

Los pinceles se almacenan en la caché para no tener que crearlos cada vez que se dibuja el rompecabezas (otra optimización del rendimiento). El pincel que muestra los valores incorrectos se selecciona si:

  • El usuario ha elegido que se le avise de los errores en la partida actual y

  • El valor de la celda no coincide con el valor del rompecabezas solucionado que creó la cuadrícula cuando inicialmente se almacenó en ella un nuevo rompecabezas.

public void SetOriginalPuzzleCheckpoint(PuzzleState original)
{
  _originalState = original;
  if (original != null)
  {
    SolverOptions options = new SolverOptions();
    options.MaximumSolutionsToFind = 2;
    SolverResults results = Solver.Solve(original, options);
    if (results.Status == PuzzleStatus.Solved &
      results.Puzzles.Count == 1)
    {
      _solvedOriginalState = results.Puzzle;
    }
    else _solvedOriginalState = null;
  }
}

La última acción de DrawToGraphics es representar la tinta que deba aparecer en la cuadrícula, iniciando una llamada a su método privado RenderInk. Esto me lleva a la parte más complicada de esta implementación de Sudoku: la compatibilidad con el lápiz de Tablet PC

Habilitación de la interacción con Tablet PC

Es muy sencillo, cualquier juego desarrollado para Tablet PC debe admitir la interacción con el lápiz. Como mínimo, la aplicación habilita el uso de un lápiz como dispositivo de navegación. Lo ideal es que la aplicación sea compatible con el reconocimiento de escritura a mano como medio de entrada de datos, pero existen varios grados de compatibilidad a este respecto. Por este motivo, el trabajo que requiere la interacción con el lápiz es variable. Las aplicaciones también pueden aprovecharse de la compatibilidad de movimientos, que permite que ciertos movimientos del lápiz se interpreten como acciones en lugar de tinta que deba reconocerse. Y las aplicaciones que vayan a ejecutarse en una pantalla táctil de Tablet PC, como PC ultra móvil, deberían considerar la compatibilidad con el tacto durante las fases de diseño y desarrollo. Mi implementación de Sudoku hace frente a todos estos principios.

Uno de los requisitos principales de un juego Sudoku para Tablet PC es que puedan escribirse los números en las celdas. Esta característica es la que proporciona al juego esa sensación de escribir en papel. No importa que los números escritos a mano tengan que representarse a continuación como números tecleados en lugar de quedarse escritos; lo importante es que la aplicación pueda reconocer la entrada. En mi caso, decidí reconocer cada pieza de entrada individualmente, representando todos los números escritos a mano en un texto escrito con el teclado, en lugar de hacerlo en el texto original.

El control PuzzleGrid se encarga de la interacción completa con el lápiz. PuzzleGrid tiene dos miembros privados de tipos que pertenecen al ensamblado Microsoft.Ink.dll.

private InkOverlay _inkOverlay;
private RecognizerContext _recognizerCtx;

La clase InkOverlay sirve de base para todo el tratamiento de las interacciones con el lápiz. InkOverlay se agrega a cualquier Control existente, lo que permite la interacción del lápiz con dicho control. La clase RecognizerContext proporciona la base para el reconocimiento de la escritura a mano que he utilizado para reconocer los números escritos por el usuario.

El método EnableTabletSupport de PuzzleGrid inicializa tanto InkOverlay como RecognizerContext. En primer lugar, recupero el reconocedor predeterminado que se va a utilizar desde la clase PlatformDetection mediante la lógica que he descrito anteriormente. A continuación, utilizo el Recognizer devuelto para configurar RecognizerContext

Recognizer defaultRecognizer =
  PlatformDetection.GetDefaultRecognizer();
_recognizerCtx = defaultRecognizer.CreateRecognizerContext();
_recognizerCtx.Factoid = Factoid.Digit;

RecognizerContext expone una propiedad Factoid que permite ofrecer pistas a RecognizerContext sobre el tipo de entrada que se le va a pedir que reconozca. Limitando de forma adecuada el reconocedor con controles, mejoro notablemente la calidad de interacción del usuario con la aplicación, al dirigir al reconocedor hacia la clasificación de la entrada especificada. En el caso del Sudoku, las únicas entradas que se le pedirá al reconocedor que identifique serán los números del 1 al 9. Predispongo al reconocedor a esta entrada configurando su propiedad Factoid en Factoid.Digit. Factoid.Digit establece que el reconocedor se incline por un solo dígito, de manera que espera y devuelve únicamente valores de un dígito (tenga en cuenta que sólo algunos controles funcionan con los reconocedores de todos los idiomas; afortunadamente, Digit es uno de ellos).

Tras configurar RecognizerContext, inicializo InkOverlay.

_inkOverlay = new InkOverlay(this, true);
_inkOverlay.Ink.CustomStrokes.Add(ScratchpadStrokesID,
  _inkOverlay.Ink.CreateStrokes());
_inkOverlay.Ink.CustomStrokes.Add(NormalStrokesID,
  _inkOverlay.Ink.CreateStrokes());
_inkOverlay.DefaultDrawingAttributes.Color = _inkColor;

Se crea una instancia de InkOverlay y se enlaza a PuzzleGrid (la referencia this, ya que este código existe dentro de la clase PuzzleGrid). Agrego dos colecciones CustomStrokes a InkOverlay. Trataré esto más adelante en el artículo pero, en resumen, facilitarán la separación de los distintos tipos de tinta que proporciona el usuario y permitirán fácilmente su persistencia en las partidas guardadas. A continuación, configuro el color de la tinta de superposición y defino CollectionMode de superposición.

La propiedad CollectionMode indica a la superposición si debe tratar las entradas como tinta, movimientos (movimientos del lápiz que se interpretan como acciones específicas en lugar de interpretarse como contenido), o ambos. Si se trata de activar InkOverlay para la colección de movimientos, se produce un error que indica que no hay ningún reconocedor de movimiento; por lo tanto, utilizo de nuevo la clase PlatformDetection para determinar si hay disponible un reconocedor de movimientos. Si lo hay, establezco CollectionMode.InkAndGesture; de lo contrario, establezco CollectionMode.InkOnly.

bool gestureRecognizerInstalled =
  PlatformDetection.GestureRecognizerInstalled;
_inkOverlay.CollectionMode = gestureRecognizerInstalled ?
CollectionMode.InkAndGesture : CollectionMode.InkOnly;

La configuración de CollectionMode indica a InkOverlay si puede aceptar movimientos, pero no especifica cuáles ni cuándo van a tener lugar. En el caso del Sudoku, lo único que me preocupa es ApplicationGesture.Scratchout, que utilizo para permitir que los usuarios tachen los números que han especificado previamente, borrándolos del tablero de juego. Para permitir la compatibilidad con los movimientos, utilizo el método InkOverlay.SetGestureStatus y registro un controlador de eventos con el evento InkOverlay.Gesture para controlar la recepción de un movimiento de tachado.

if (gestureRecognizerInstalled)
{
  _inkOverlay.SetGestureStatus(
    ApplicationGesture.AllGestures, false);
  _inkOverlay.SetGestureStatus(
    ApplicationGesture.Scratchout, true);
  _inkOverlay.Gesture +=
    new InkCollectorGestureEventHandler(HandleGesture);
}

A continuación, configuro las propiedades AutoRedraw y DynamicRendering de la clase InkOverlay. Si AutoRedraw se establece en true, InkOverlay representa de forma automática todos los trazos completados que captura. Si DyamicRendering se establece en true, InkOverlay representa de forma automática todos los trazos completados que ha capturado. Para permitir el uso del búfer doble en la representación de la tinta, he decidido dibujarla yo mismo, y por lo tanto establecer AutoRedraw en false. Sin embargo, estoy muy satisfecho al permitir que InkOverlay represente la tinta que el usuario escribe actualmente y, por lo tanto, establecer el valor true para DynamicRendering.

_inkOverlay.AutoRedraw = false;
_inkOverlay.DynamicRendering = true;

Ya casi he terminado de configurar InkOverlay. Necesito indicar a InkOverlay cómo debe responder ante ciertas interacciones del lápiz y, para hacerlo, utilizo controladores registrados para un grupo de eventos en InkOverlay.

  • El evento Stroke se genera cuando el usuario completa un nuevo trazo.

  • El evento CursorInRange se genera cuando el lápiz entra en el rango de detección física (proximidad) del contexto de Tablet PC.

  • El evento NewPackets se genera cuando InkOverlay recibe nuevos paquetes que contienen datos de un lápiz que está tocando la pantalla.

  • El evento NewInAirPackets se genera cuando InkOverlay recibe paquetes que contienen datos sobre movimientos en el aire de un lápiz que está encima de la pantalla.

  • El evento StrokesDeleting se genera cuando se quita algún trazo de la colección Strokes de la clase InkOverlay.

Nota

Los eventos CursorInRange y NewInAirPackets se incluyen para digitalizadores electromagnéticos y mouse, pero no para digitalizadores táctiles.

_inkOverlay.Stroke += new InkCollectorStrokeEventHandler(HandleStroke);
_inkOverlay.CursorInRange +=
  new InkCollectorCursorInRangeEventHandler(HandleCursorInRange);
_inkOverlay.NewPackets +=
  new InkCollectorNewPacketsEventHandler(HandleNewPackets);
_inkOverlay.NewInAirPackets +=
  new InkCollectorNewInAirPacketsEventHandler(HandleNewInAirPackets);
_inkOverlay.StrokesDeleting +=
new InkOverlayStrokesDeletingEventHandler(HandleStrokesDeleting);

Finalmente, se habilita el InkOverlay.

_inkOverlay.Enabled = true;

Reconocimiento de números creados a partir de varios trazos

Cuando hay que reconocer la entrada del usuario, empleo mi método RecognizeStrokes.

private bool RecognizeStrokes(Strokes strokes, out byte number)
{
  number = 0;
  if (_recognizerCtx != null & strokes.Count > 0)
  {
    _recognizerCtx.Strokes = strokes;
    _recognizerCtx.EndInkInput();

    RecognitionStatus rs;
    RecognitionResult rr = _recognizerCtx.Recognize(out rs);
    if (rr != null & rs == RecognitionStatus.NoError)
    {
      string inputNumberText = rr.TopString;
      if (inputNumberText != null & inputNumberText.Length > 0)
      {
        try
        {
          number = byte.Parse(inputNumberText,
            CultureInfo.InvariantCulture);
        }
        catch(OverflowException){}
        catch(FormatException){}
        if (number >= 1 & number <= 9) return true;
      }
    }
  }
  return false;
}

RecognizeStrokes acepta dos parámetros: la colección de trazos que se van a reconocer y un byte de salida que contiene el dígito reconocido. Devuelve un valor booleano que indica si la entrada se ha reconocido correctamente, es decir, que el número de salida puede usarse y procesarse como entrada. En primer lugar, RecognizeStrokes realiza una comprobación para asegurarse de que RecognizerContext es válido y si se le ha asignado algún trazo. Asumiendo que se dan ambas condiciones, guarda los trazos en RecognizerContext y emplea el método EndInkInput para notificar al RecognizerContext que he terminado de aceptar la entrada para su reconocimiento. A continuación, llamo al método Recognize para llevar a cabo el reconocimiento.

Recognize proporciona cierta información. El objeto RecognitionResult que devuelve Recognize contiene información sobre el resultado de la operación, y es posible utilizar el valor de enumeración RecognitionStatus proporcionado como un parámetro de salida del método, para determinar si se ha producido algún error durante el reconocimiento y, si es así, el tipo. La propiedad TopString del objeto RecognitionResult se devuelve como cadena del texto reconocido que, en este contexto, debería tratarse de un solo dígito. A continuación, utilizo el método byte.Parse para comprobar si el dígito se ha reconocido y, si es así, el número se devuelve al autor de la llamada.

Puedo utilizar RecognizeStrokes cada vez que el usuario realiza un trazo, que es exactamente lo que hice en mi implementación original de Sudoku. Pero esto supone un problema para los números que se escriban con múltiples trazos (al principio no pensé en ello, porque escribo todos los dígitos con un único trazo continuo). En cuanto se recibiera un trazo de una entrada de varios trazos, se llamaría a RecognizeStrokes y, en la mayoría de los casos, no reconocería correctamente la entrada, ya que todavía queda una buena parte del número por escribir.

Solucioné este problema en el Sudoku utilizando un temporizador. Cuando InkOverlay recibe un Stroke, pongo en marcha un temporizador que tiene un período de medio segundo. Si InkOverlay recibe otro trazo antes de que el tiempo termine, vuelvo a poner a cero el temporizador, permitiendo otro medio segundo. A veces, el temporizador termina antes de que el jugador agregue más trazos. Cuando el tiempo termina, llamo al controlador de eventos para el temporizador, donde reconozco la entrada del usuario y la guardo en la celda apropiada del rompecabezas, en función de la ubicación de los trazos especificados por el jugador.

if (strokes.Count > 0)
{
  byte number = 0;
  bool recognized = false;

  Point cell = GetCellFromStroke(strokes[0]);
  if (CanModifyCell(cell))
  {
    recognized = RecognizeStrokes(strokes, out number);
  }

  _inkOverlay.Ink.DeleteStrokes(strokes);
  strokes.Clear();

  if (recognized) SetStateCell(cell, (byte)(number-1));
}

Sin embargo, debería tener en cuenta que se tratan todos los trazos del mismo modo. En concreto, ignoro los trazos que creo que son errores, como cuando el lápiz roza accidentalmente la pantalla o el usuario toca en la pantalla para cambiar la celda seleccionada. Para ello, obtengo el rectángulo del cuadro de límite para Stroke, convirtiendo las coordenadas de un espacio de tinta en un espacio de control.

Rectangle boundingBox = e.Stroke.GetBoundingBox();
InkToPixelSpace(ref boundingBox);

A continuación, comparo el ancho y alto del cuadro de límite con el ancho y alto de una celda de la cuadrícula. Si uno de los dos es menor que alguna fracción predeterminada del tamaño de celda, entonces ignoro el Stroke. Para ello, se establece la propiedad Cancel del argumento del evento en true, de manera que la API de Tablet PC borra el trazo automáticamente. También necesito asegurarme de que reinicio el temporizador de trazos múltiples por si se ha especificado algún trazo normal y aún no ha sido reconocido.

RectangleF cellRect = GetCellRectangle(ClientRectangle, Point.Empty);
if (boundingBox.Width <
    cellRect.Width * MINIMINUM_BOUNDING_RATIO_TO_RECOGNIZE &
  boundingBox.Height <
    cellRect.Height * MINIMINUM_BOUNDING_RATIO_TO_RECOGNIZE)
{
  e.Cancel = true;
  ...
}

También utilizo otros eventos para reconocer trazos. Doy por supuesto que si los usuarios mueven el lápiz a una celda diferente, bien se haya presionado sobre la pantalla o levantado el lápiz, es que han terminado de escribir el número de la celda anterior. Por lo tanto, en este punto, detengo el temporizador y reconozco automáticamente los trazos, como si el temporizador hubiese terminado. (RecognizePreviousCellFromPacket realiza una comprobación para ver si los trazos actuales están en una celda diferente de la celda donde se encuentra el lápiz; si es así y los trazos están en una celda que puede editarse, llama a RecognizeStrokes.)

private void HandleNewInAirPackets(
  object sender, InkCollectorNewInAirPacketsEventArgs e)
{
  if (e.PacketData.Length >= 2)
  {
    RecognizePreviousCellFromPacket(
      new Point(e.PacketData[0], e.PacketData[1]));
  }
}

La misma lógica se utiliza como respuesta al evento NewPackets y al evento Stroke. La figura 12 muestra los trazos correspondientes a un número que se ha especificado con varios trazos (por ejemplo, dos trazos para crear el número 4), y la figura 13 muestra el resultado, una vez que se ha reconocido y representado el número.

Figura 12. El dígito 4 formado por varios trazos

Figura 13. El dígito 4 reconocido a partir de varios trazos

Compatibilidad con la función de deshacer

Una de mis funciones favoritas en una aplicación es la posibilidad de deshacer varios niveles, función que, por supuesto, he implementado en mi aplicación de Sudoku. Y, gracias a mi implementación de PuzzleState.Clone, esta función es bastante fácil de implementar.

La idea básica es que cada vez que se realiza un cambio en el tablero de juego, envío una copia del estado actual a la pila de la función de deshacer.

private PuzzleStateStack _undoStates = new PuzzleStateStack();

El tipo PuzzleStateStack es una clase pequeña, con establecimiento inflexible de tipos, derivada de la clase Stack en System.Collections. Más tarde, cuando quiero deshacer y volver a un estado anterior, saco un estado de la pila de la función de deshacer y lo establezco como estado actual.

public void Undo()
{
  if (_undoStates.Count > 0) this.State = _undoStates.Pop();
}

La clave es asegurarse de que cualquier cambio realizado al estado actual inserta el estado actual en la pila de deshacer. Internamente, PuzzleGrid puede garantizar que inserta el estado en la pila cada vez que se realiza un cambio, pero PuzzleGrid expone públicamente la instancia PuzzleState actual y, con esa implementación, no puedo estar seguro de que queden registrados correctamente todos los cambios. Por tanto, PuzzleGrid expone un miembro SetUndoCheckpoint y pasa la responsabilidad al consumidor del control para garantizar que se llama a SetUndoCheckpoint antes de que se realicen cambios significativos del estado actual.

public void SetUndoCheckpoint()
{
  _undoStates.Push(State.Clone());
}

PuzzleGrid también utiliza SetUndoCheckpoint internamente. Como ejemplo de dónde y cuándo se llama, considere el método SetStateCell, utilizado en el controlador de eventos del temporizador de varios trazos descrito en la sección anterior.

private void SetStateCell(Point cell, byte number)
{
  if (State[cell] != number)
  {
    SetUndoCheckpoint();
    ClearTabletStateCell(cell);
    State[cell] = number;
  }
}

Este método primero comprueba que el nuevo valor de la celda de destino no es el mismo que el valor que ya hay. Asumiendo que se necesita un cambio, el método establece un punto de comprobación de la función de deshacer e inserta el PuzzleState actual en la pila de la función de deshacer. A continuación, el método agrega el valor a la celda. Adicionalmente, el método llama al método ClearTabletStateCell. ClearTabletStateCell está relacionado con la función de bloc de notas del juego.

Compatibilidad con la función de bloc de notas

Una de las principales funciones que he implementado en el Sudoku para dar la sensación de estar jugando sobre el papel es la función que permite realizar anotaciones, denominada bloc de notas (scratchpad) en el código. La idea es que se puedan tomar notas que permitan al usuario llevar un seguimiento de sus deducciones lógicas durante la partida, del mismo modo en que lo haría en un rompecabezas sobre papel (vea la figura 14). Estas notas no se reconocen como lo es la tinta escrita con el lápiz (denominada normal en el código), sino que se almacenan junto con PuzzleState como datos de etiquetas adicionales.

Figura 14. Anotaciones realizadas con la función de bloc de notas

Cuando comentaba la creación de instancias y configuración de InkOverlay, mencioné brevemente dos instrucciones.

_inkOverlay.Ink.CustomStrokes.Add(ScratchpadStrokesID,
  _inkOverlay.Ink.CreateStrokes());
_inkOverlay.Ink.CustomStrokes.Add(NormalStrokesID,
_inkOverlay.Ink.CreateStrokes());

La propiedad Ink de la clase InkOverlay devuelve el objeto Ink, actualmente asociado con la superposición, y su método CreateStrokes crea una nueva colección Strokes que está asociada con dicho objeto Ink. Al crear y mantener dos colecciones Strokes diferentes, puedo separar fácilmente los trazos que se crearon en el modo de lápiz normal de los creados en el modo de lápiz bloc de notas. Esto facilita la realización de operaciones en sólo uno de estos tipos de trazos. Para simplificar la recuperación de las colecciones y garantizar que se serializan correctamente junto con el objeto Ink, ambas colecciones se agregan a la colección CustomStrokes del objeto Ink (la serialización es el proceso de convertir el objeto Ink en una matriz de bytes para su almacenamiento. Utilizo esto para dos propósitos diferentes, como explicaré más adelante). La colección CustomStrokes contiene colecciones Strokes con nombre que se almacenan para usarse en el futuro. He definido algunas propiedades auxiliares que simplifican la consulta y el trabajo con estas colecciones.

private Strokes NormalStrokes
{
  get { return _inkOverlay.Ink.CustomStrokes[NormalStrokesID]; }
}
private Strokes ScratchpadStrokes
{
  get { return _inkOverlay.Ink.CustomStrokes[ScratchpadStrokesID];}
}
private bool HasScratchpadStrokes
{
  get
  {
    using(Strokes strokes = ScratchpadStrokes)
    {
      return strokes.Count > 0;
    }
  }
}

Tenga en cuenta que HasScratchpadStrokes, que devuelve true si la colección de trazos de la función de bloc de notas contiene algún trazo, elimina la colección Strokes de la función de bloc de notas cuando termina de usarla. Parece raro, hasta que se examina lo que hace realmente el indizador CustomStrokes. A continuación, se muestra un seudocódigo para su implementación:

public Strokes this[string name]
{
  get { return new Strokes(this.m_CustomStrokes.Item(name)); }
}

Devuelve un nuevo objeto Strokes en lugar del objeto que se almacenó originalmente en la colección. Éste es el objeto Strokes que se elimina, no el original. Lo mismo sucede cuando se realiza el acceso a la colección Strokes principal desde el objeto Ink en InkOverlay. A continuación se muestra el seudocódigo para que el objeto Strokes obtenga el descriptor de acceso.

public Strokes Strokes
{
  get
  {
    if (this.disposed)
    {
      throw new ObjectDisposedException(GetType().FullName);
    }
    return new Strokes(this.m_Ink.Strokes);
  }
}

De nuevo, se devuelve una nueva instancia Strokes cada vez que se realiza el acceso a la propiedad Strokes y es necesario asegurarse de que se eliminan los objetos cuando se ha terminado con ellos. Como ejemplo, el siguiente código muestra el método RenderInk que PuzzleGrid emplea para dibujar manualmente toda la tinta (recuerde que desactivamos AutoRedraw en InkOverlay).

private void RenderInk(Graphics graphics)
{
  if (_inkOverlay != null)
  {
    using(Strokes strokes = _inkOverlay.Ink.Strokes)
    {
      if (strokes.Count > 0)
      {
        _inkOverlay.Renderer.Draw(graphics, strokes);
      }
    }
  }
}

PuzzleGrid realiza un seguimiento de si la cuadrícula está o no en modo bloc de notas; este conmutador se controla con los botones del lápiz y bolígrafo del formulario principal. Cuando se selecciona el modo bloc de notas, el controlador de eventos para el evento Stroke en InkOverlay no lleva a cabo la lógica para el reconocimiento de varios trazos descrita anteriormente. En su lugar, agrega cada trazo recibido a la colección Strokes del bloc de notas personalizado. Sin embargo, es necesario realizar ciertas tareas adicionales.

En primer lugar, cada trazo del bloc de notas debe almacenarse como su propio cambio en la pila de la función de deshacer para así poder deshacer los cambios en las notas individuales. Esto es difícil, ya que no hay ningún evento de InkOverlay que se haya desencadenado antes de que los datos del trazo se hayan agregado al objeto Ink. Para resolver esto, se necesitan unos pequeños trucos. Lo que debo hacer es eliminar el trazo del objeto Ink, establecer un punto de comprobación de la función de deshacer y, a continuación, volver a agregar el trazo. Desafortunadamente, un trazo eliminado no se puede utilizar. La solución es serializar el trazo, eliminar el original y, a continuación, tras establecer el punto de comprobación de la función de deshacer, crear un trazo completamente nuevo basado en los datos del anterior. Así, puedo volver a agregar este nuevo trazo al objeto Ink.

Stroke s = e.Stroke;
TabletPropertyDescriptionCollection tpdc =
  new TabletPropertyDescriptionCollection();
foreach (Guid g in s.PacketDescription)
{
  TabletPropertyDescription tpd = new TabletPropertyDescription(
    g, e.Cursor.Tablet.GetPropertyMetrics(g));
  tpdc.Add(tpd);
}
int[] packetData = e.Stroke.GetPacketData();
_inkOverlay.Ink.DeleteStroke(e.Stroke);
SetUndoCheckpoint();
s = _inkOverlay.Ink.CreateStroke(packetData, tpdc);

Un Stroke está compuesto por una serie de puntos, cada uno de los cuales es el resultado de varios paquetes de datos. Se utiliza el método GetPacketData en Stroke para recuperar una matriz de este paquete de datos; y el método CreateStroke en el objeto Ink tiene una sobrecarga que acepta dicha matriz de datos y crea un nuevo Stroke a partir de ella. La cuestión es que este método también requiere una descripción del contenido del paquete de datos y esta descripción está en forma de una colección TabletPropertyDescriptionCollection. Por tanto, antes de borrar el Stroke, es necesario examinarlo para obtener esta información. La propiedad PacketDescription en Stroke devuelve una matriz de Guid, uno por cada tipo de datos del paquete almacenado en Stroke. Éstas pueden enumerarse para construir TabletPropertyDescriptionCollection, necesaria para invocar a CreateStroke.

Una vez lograda la compatibilidad con la función de deshacer, aplico a los trazos del bloc de notas un color diferente para diferenciarlos de los trazos normales.

s.DrawingAttributes.Color = _scratchpadInkColor;

Asimismo, existe otro truco en forma de fragmento de código. Anteriormente, en este artículo, describí ampliamente la importancia de utilizar formularios redimensionables y mostré cómo varios controles, como PuzzleGrid, se adaptan para convertirse en redimensionables. Cada objeto de trazo también necesita cambiar de tamaño y de posición para mantener su tamaño y posición en relación con PuzzleGrid. Es más fácil decirlo que llevarlo a la práctica. Mi solución es que cada trazo del bloc de notas debe tener asociado, en el momento de su creación, el tamaño actual del trazo y de la cuadrícula. Cada vez que se cambia el tamaño de la cuadrícula, esta información permite que el trazo cambie su tamaño en relación a ésta. Al basar estas transformaciones en el tamaño y posición originales del trazo en lugar del tamaño y posición actuales, evito problemas provocados por errores derivados que están relacionados con el almacenamiento de estos datos en números en punto flotante; si se cambia el tamaño con frecuencia, estos errores pueden provocar, con el tiempo, desplazamientos de la tinta.

Toda esta información se almacena en ExtendedProperties, en el objeto Stroke. Las aplicaciones pueden usar Stroke.ExtendedProperties para tener acceso a los datos personalizados almacenados en el objeto Stroke. Estos datos personalizados se serializan con el objeto de forma automática. Por tanto, almaceno 6 datos en ExtendedProperties: la ubicación (x,y) y el tamaño (ancho y alto) del cuadro de límite de Stroke, y el tamaño actual del control PuzzleGrid (ancho y alto). El primer parámetro del método Add en ExtendedProperties es un Guid, que se utiliza para identificar la propiedad ampliada particular que se está creando. Puede ser cualquier Guid que desee, pero si está serializando Stroke en un almacén persistente para poder recuperar los Stroke en una ejecución posterior de la aplicación, asegúrese de que los Guid que usa para sus propiedades ampliadas se mantienen constantes durante las distintas ejecuciones de la aplicación.

Rectangle boundingBox = s.GetBoundingBox();
using(Graphics graphics = CreateGraphics())
{
  InkSpaceToPixelSpace(graphics, ref boundingBox);
  s.ExtendedProperties.Add(OriginalStrokeBoundRectXGuid,
    boundingBox.X);
  s.ExtendedProperties.Add(OriginalStrokeBoundRectYGuid,
    boundingBox.Y);
  s.ExtendedProperties.Add(OriginalStrokeBoundRectWidthGuid,
    boundingBox.Width);
  s.ExtendedProperties.Add(OriginalStrokeBoundRectHeightGuid,
    boundingBox.Height);
  s.ExtendedProperties.Add(OriginalClientRectWidthGuid,
    ClientRectangle.Width);
  s.ExtendedProperties.Add(OriginalClientRectHeightGuid,
    ClientRectangle.Height);
}

Más adelante, al cambiar el tamaño de PuzzleGrid, utilizaré esta información para modificar cada Stroke.

protected override void OnResize(EventArgs e)
{
  _cachedEmSize = -1;
  ResizeScratchpadInk();
  base.OnResize(e);
}
internal void ResizeScratchpadInk()
{
  if (_inkOverlay != null)
  {
    Rectangle currentClientRect = ClientRectangle;
    using(Strokes scratchpadStrokes = ScratchpadStrokes)
    {
      foreach(Stroke s in scratchpadStrokes)
      {
        int originalBoundsX = (int)s.ExtendedProperties[
          OriginalStrokeBoundRectXGuid].Data;
        int originalBoundsY = (int)s.ExtendedProperties[
          OriginalStrokeBoundRectYGuid].Data;
        int originalBoundsWidth = (int)s.ExtendedProperties[
          OriginalStrokeBoundRectWidthGuid].Data;
        int originalBoundsHeight = (int)s.ExtendedProperties[
          OriginalStrokeBoundRectHeightGuid].Data;
        int originalClientRectWidth =
          (int)s.ExtendedProperties[
            OriginalClientRectWidthGuid].Data;
        int originalClientRectHeight =
          (int)s.ExtendedProperties[
            OriginalClientRectHeightGuid].Data;
        double scaleX = currentClientRect.Width /
          (double)originalClientRectWidth;
        double scaleY = currentClientRect.Height /
          (double)originalClientRectHeight;

        Rectangle newBounds = new Rectangle(
          (int)(originalBoundsX*scaleX),
          (int)(originalBoundsY*scaleY),
          (int)(originalBoundsWidth*scaleX),
          (int)(originalBoundsHeight*scaleY));

        using(Graphics graphics = CreateGraphics())
        {
          PixelSpaceToInkSpace(graphics, ref newBounds);
          s.ScaleToRectangle(newBounds);
        }
      }
    }
  }
}

Para cada trazo, calculo la relación entre el tamaño actual del control y el tamaño del control cuando se creó el trazo (tenga en cuenta que debe calcularse para cada trazo, ya que los trazos pueden haberse creado con tamaños de control "originales" diferentes, principalmente, si se cambió el tamaño de PuzzleGrid entre la creación de los trazos). A continuación, utilizo esta relación junto con los límites originales del trazo para calcular cuáles deberían ser los nuevos límites del trazo. Después, proporciono esta información al método ScaleToRectangle en Stroke, que realiza la operación de cambio de tamaño real en Stroke.

Tener los trazos del bloc de notas en su propia colección resulta útil cuando se cambia entre los modos normal y bloc de notas. Una de las distinciones visuales que esta aplicación realiza entre ambos modos es que, en el modo normal, la tinta del bloc de notas aparece atenuada, mientras que en el modo bloc de notas la tinta es de color negro. Esto se consigue cambiando los atributos DrawingAttributes en cada instancia Stroke del bloc de notas. Por ejemplo, para cambiar el color de cada trazo a gris, se utiliza el siguiente código.

using(Strokes strokes = ScratchpadStrokes)
{
  foreach(Stroke s in strokes)
  {
    s.DrawingAttributes.Color = Color.Gray;
  }
}

Tener los trazos del bloc de notas en su propia colección también resulta útil cuando se escriben números en las celdas. Cuando un usuario escribe un número en una celda, se llama al método SetStateCell (explicado anteriormente). Este método, a su vez, llama al método ClearTabletStateCell. ClearTabletStateCell quita cualquier trazo del bloc de notas que haya en la celda, al considerar que el usuario acaba de especificar un número deducido por lógica en la celda de destino y, por tanto, se pueden eliminar las notas asociadas a esta celda para evitar aglomeraciones innecesarias. ClearTabletStateCell realiza iteraciones por todos los trazos de la colección de trazos del bloc de notas y elimina aquéllos que están en la celda de destino.

private void ClearTabletStateCell(Point cell)
{
  if (_inkOverlay != null)
  {
    // Delete any scratchpad strokes in that cell
    using(Strokes strokes = ScratchpadStrokes)
    {
      for(int i=strokes.Count-1; i>=0; --i)
      {
        Stroke s = strokes[i];
        if (GetCellFromStroke(s) == cell)
        {
          strokes.RemoveAt(i);
          _inkOverlay.Ink.DeleteStroke(s);
        }
      }
    }
  }
}

Compatibilidad con la función de borrar

Una nueva función que admiten muchos equipos Tablet PC es la función de borrado con el lápiz. Dele la vuelta al lápiz y podrá utilizar la parte trasera como un borrador virtual, siempre y cuando la aplicación sea compatible. El Sudoku lo es.

La clase InkOverlay expone una propiedad EditingMode de tipo InkOverlayEditingMode. El valor predeterminado para esta propiedad es InkOverlayEditingMode.Ink. Sin embargo, cuando se establece en InkOverlayEditingMode.Delete, el cursor se vuelve un borrador y, si se arrastra sobre los trazos existentes, los borra, en lugar de crear y almacenar nuevos trazos de tinta. Normalmente, las aplicaciones comprueban y establecen esta propiedad en un controlador de eventos para el evento CursorInRange en InkOverlay, y eso es exactamente lo que hace el Sudoku. La instancia de InkCollectorCursorInRangeEventArgs que se proporciona como parámetro a un controlador de eventos para el evento CursorInRange tiene una propiedad Cursor que devuelve una instancia de Microsoft.Ink.Cursor, y este objeto tiene una propiedad Inverted booleana que devuelve si el lápiz está hacia abajo y, por tanto, si el usuario tiene intención de usar el borrador. Mi controlador de eventos responde estableciendo convenientemente el modo EditingMode en InkOverlay.

Nota

La detección de paquetes en el aire funciona con los digitalizadores electromagnéticos y el mouse, pero no con los digitalizadores táctiles.

Cuando se recibe un trazo de borrado, se genera el evento Stroke en InkOverlay. Sin embargo, en lugar de utilizar la lógica de reconocimiento de varios trazos, que tiene lugar cuando se establece EditingMode en InkOverlayEditingMode.Ink, ejecuto el código para borrar la celda situada debajo del borrador.

Point currentStrokeCell = GetCellFromStroke(e.Stroke);
if (CanClearCell(currentStrokeCell))
{
  ClearStateCell(currentStrokeCell);
}

Esto borra sólo una celda, ya que GetCellFromStroke elige la celda de destino más probable basada en el cuadro de límite en Stroke. Sin embargo, cuando un jugador arrastra el borrador sobre varias celdas, lo más probable es que desee borrar todas las celdas tocadas. Para borrar todas las celdas tocadas cuando el usuario arrastre el borrador sobre ellas, se agrega una lógica similar a un controlador de evento para el evento NewPackets en InkOverlay.

if (_mode == PuzzleGridMode.Eraser || e.Cursor.Inverted)
{
  if (e.PacketData.Length >= 2)
  {
    Point cell = TabletToCell(
      new Point(e.PacketData[0], e.PacketData[1]));
    if (CanClearCell(cell) & State[cell].HasValue)
    {
      ClearStateCellWithInvalidation(cell);
    }
    if (_selectedCell.HasValue)
    {
      InvalidateCell(_selectedCell.Value);
    }
    SetSelectedCell(cell);
    InvalidateCell(_selectedCell.Value);
  }
}

Cuando se selecciona el modo bloc de notas, aplico una lógica diferente. El tratamiento predeterminado de InkOverlayEditingMode.Delete en InkOverlay es quitar todos los trazos situados debajo del cursor. Éste es el comportamiento que deseo en el modo bloc de notas, así que dejo que InkOverlay lo haga. Sólo hay dos cuestiones: primero, necesito establecer un punto de función de deshacer antes de borrar un trazo para poder recuperar el trazo borrado. Segundo, necesito quitar manualmente los trazos borrados de las colecciones personalizadas. Puedo llevar a cabo las dos tareas en un controlador de eventos para el evento StrokesDeleting en InkOverlay.

private void HandleStrokesDeleting(
  object sender, InkOverlayStrokesDeletingEventArgs e)
{
  SetUndoCheckpoint();
  using(Strokes normalStrokes = NormalStrokes)
  {
    normalStrokes.Remove(e.StrokesToDelete);
  }
  using(Strokes scratchpadStrokes = ScratchpadStrokes)
  {
    scratchpadStrokes.Remove(e.StrokesToDelete);
  }
}

Como ya he comentado, un jugador también puede utilizar el movimiento de tachado para borrar un número escrito previamente en una celda (los jugadores también pueden utilizar el movimiento de tachado para borrar las notas creadas en el modo bloc de notas), tal como se muestra en la figura 15.

Figura 15. Número tachado para borrarlo

En el modo normal, esto implica responder al evento Gesture mediante la determinación de la celda sobre la que se ha realizado el movimiento y, a continuación, borrar dicha celda. Una cuestión importante a tener en cuenta es que el borrado de la celda hace que se establezca un punto de comprobación de la función de deshacer. Este punto de comprobación incluye todos los trazos almacenados en InkOverlay. En este punto, los trazos del movimiento permanecen en superposición, por lo que borro de forma explícita los trazos del movimiento antes de borrar la celda.

void HandleGestureInNormalMode(
  object sender, InkCollectorGestureEventArgs e)
{
  switch (e.Gestures[0].Id)
  {
    case ApplicationGesture.Scratchout:
      Point cell = GetCellFromStroke(e.Strokes[0]);
      _inkOverlay.Ink.DeleteStrokes(e.Strokes);
      RecognizePreviousNormalStrokes();
      if (CanClearCell(cell)) ClearStateCell(cell);
      break;
    default:
      e.Cancel = true;
      break;
  }
  Invalidate();
}

Trabajar con movimientos de tachado en el modo bloc de notas nunca había sido tan fácil. La aplicación recupera los trazos del bloc de notas que se cruzan con los trazos de movimientos de tachado y los borra.

Animación de partida ganada

Ningún juego está completo sin el reconocimiento adecuado de la victoria de los participantes. Sin lugar a dudas, hay un cierto orgullo cuando se rellena la última celda y queda demostrado el poder de la lógica, pero también se agradece que el juego refuerce este sentimiento vencedor, por ejemplo, del modo en que lo hace la implementación del Solitario de Microsoft (vea la figura 16).

Figura 16. Ventana del Solitario tras ganar una partida

Tras implementar varias animaciones de partida ganada en Sudoku y escuchar algunas buenas sugerencias de mi novia Tamara (fan del Sudoku y mi mejor probadora de versiones beta), me decanté por una animación donde las celdas salen disparadas del tablero, rotando, girando y flotando, con el texto "Congratulations!" (¡Felicidades!) en amarillo brillante. Además, conservé una imagen difuminada del tablero completado detrás de los números giratorios, tras saber que los jugadores querían ver su partida finalizada además de la animación de mosaicos. Puede ver un ejemplo de la animación en la figura 17.

Figura 17. Ventana de Sudoku tras resolver un rompecabezas

Construí la animación en un control denominado WinningAnimation, disponible en el archivo WinningAnimation.cs en la carpeta Controls. Cuando se inicializa el primer formulario, se agrega una instancia de WinningAnimation a la colección Controls en PuzzleGrid, acoplada para rellenar el control de la cuadrícula principal, y oculta.

_winningAnimation = new WinningAnimation(thePuzzleGrid);
thePuzzleGrid.Controls.Add(_winningAnimation);
_winningAnimation.Dock = DockStyle.Fill;
_winningAnimation.Visible = false;
_winningAnimation.BringToFront();

Cuando un jugador resuelve el rompecabezas, se muestra el control de la animación, haciendo que ésta comience.

La animación está basada en un sistema de sprites. Sprites es simplemente un término divertido para designar a las imágenes o animaciones integradas en una escena más grande. Normalmente, pueden mantener su estado propio, moverse por la pantalla e interactuar con otros sprites, y pueden hacer otras cosas, como representarse a sí mismos. En esta animación, cada celda que vuela tiene su propio sprite.

internal class ImageSprite : IDisposable
{
  Point _location;
  float _angle;
  float _flipPosition;

  Size _velocity;
  float _angularVelocity;
  float _flipSpeed;
  bool _rotateAroundCenter;

  private Bitmap _bmp;
  ...
}

Creo cada sprite con una posición inicial dentro del control y un Bitmap que contiene la imagen principal que debe representar el sprite. Durante la animación, un sprite mantiene su posición actual, velocidad lineal, velocidad angular y velocidad de volteo (que es un término que he creado para definir la velocidad angular alrededor de otro eje de rotación). También configuro cada sprite con un valor booleano que dicta si debería rotar sobre su centro o su esquina.

Cada sprite se representa a sí mismo. Cuando se representa el control principal, éste suministra cada sprite al objeto Graphics de destino y el sprite controla el dibujo en este objeto Graphics.

public void Paint(Graphics graphics)
{
  using(Matrix mx = new Matrix())
  {
    GraphicsState gs = graphics.Save();
    if (_rotateAroundCenter) mx.Translate(
      -_bmp.Width/2, -_bmp.Height/2, MatrixOrder.Append);
    mx.Rotate(_angle, MatrixOrder.Append);
    if (_rotateAroundCenter) mx.Translate(
      _bmp.Width/2, _bmp.Height/2, MatrixOrder.Append);
    mx.Translate(_location.X, _location.Y, MatrixOrder.Append);
    graphics.Transform = mx;

    // Draw the image
    float flipMult = ((float)
      Math.Cos(_flipPosition*Math.PI/180.0));
    if (flipMult > 0.001 || flipMult < -0.001)
    {
      graphics.DrawImage(_bmp,
        new RectangleF(0, 1-Math.Abs(flipMult),
        _bmp.Width, _bmp.Height*flipMult),
        new RectangleF(0, 0, _bmp.Width, _bmp.Height),
        GraphicsUnit.Pixel);
    }

    graphics.Restore(gs);
  }
}

Aquí suceden varias cosas. En primer lugar, creo un objeto System.Drawing.Drawing2D.Matrix. Si no está familiarizado con el álgebra lineal y su influencia en los gráficos, debe saber que las matrices se utilizan con frecuencia para transformar un espacio de coordenadas en otro. La clase Matrix simplifica la aplicación de transformaciones, como rotaciones y traslaciones, y controla todas las operaciones matemáticas subyacentes por el usuario. A continuación, la clase Graphics permite realizar el dibujo con estas transformaciones aplicadas.

Para empezar, guardo el estado actual en el objeto Graphics para poder restaurarlo después de dibujar todo lo necesario bajo la transformación actual. A continuación, roto la matriz que creé al ángulo actual del sprite. Tenga en cuenta que el sprite rota alrededor de su esquina porque la esquina está en la coordenada [0,0]. Si es necesario que este sprite gire alrededor de su centro, puedo aplicar una traslación antes de la rotación, que mueve el centro del sprite a [0,0]. A continuación aplico la rotación y deshago la traslación.

Tras girar el sprite convenientemente, lo muevo para corregir su ubicación (una transformación de la traslación basada en la posición actual del sprite). El paso final es dibujar el sprite y se hace mediante Graphics.DrawImage. Para facilitar el "volteo" del sprite, cambio el tamaño vertical del rectángulo de destino (como un acordeón que se estira y contrae cuando se toca). Cuando el tamaño vertical es negativo, DrawImage representa la imagen de arriba a abajo, lo que crea el efecto para el jugador de mirar a través de la parte trasera del mosaico.

El control de animación global se pinta a sí mismo gracias a la capacidad de los sprites para representarse.

protected override void OnPaint(PaintEventArgs pe)
{
  // Do base painting
  base.OnPaint(pe);

  // Draw the base underlying image
  if (_underImage != null)
  {
    pe.Graphics.DrawImage(_underImage, 0, 0);
  }

  // Render all of the sprites
  if (_sprites != null & _sprites.Count > 0)
  {
    for(int i=_sprites.Count-1; i>=0; --i)
    {
      ImageSprite s = (ImageSprite)_sprites[i];
      s.Paint(pe.Graphics);
    }
  }

  // Show the congratulatory text
  string text = ResourceHelper.PuzzleSolvedCongratulations;
  if (_sf != null & text != null & text.Length > 0)
  {
    float emSize = GraphicsHelpers.GetMaximumEMSize(text,
      pe.Graphics, Font.FontFamily, Font.Style,
      ClientRectangle.Width, ClientRectangle.Height);
    using(Font f = new Font(Font.FontFamily, emSize))
    {
      pe.Graphics.DrawString(text, f, Brushes.Black,
        new RectangleF(2, 2, ClientRectangle.Width,
          ClientRectangle.Height), _sf);
      pe.Graphics.DrawString(text, f, Brushes.Gray,
        new RectangleF(-1, -1, ClientRectangle.Width,
          ClientRectangle.Height), _sf);
      pe.Graphics.DrawString(text, f, Brushes.Yellow,
        new RectangleF(0, 0, ClientRectangle.Width,
          ClientRectangle.Height), _sf);
    }
  }
}

El método comienza con la representación de la imagen subyacente (enseguida lo aclaro) y, después, continúa con la iteración de todos los sprites y representa cada uno delegando en el método Paint del sprite, mencionado anteriormente. Por último, el método representa el texto de felicitación. Este texto se representa a través de tres llamadas a Graphics.DrawString, dos para dibujar el fondo o la sombra del texto, y una para dibujar la superposición amarilla. Al igual que el tamaño del control puede variar, también puede hacerlo el tamaño de la fuente empleada para representar el texto. El tamaño de fuente especificado se recupera mediante el método estático GetMaximumEMSize en mi clase GraphicsHelpers. Este método acepta el texto que se va a representar, el objeto de gráficos de destino, la familia de fuentes especificada y el ancho y alto del rectángulo al que debe ajustarse el texto. A continuación, devuelve el tamaño de fuente más grande que permite que el texto especificado se ajuste en un cuadro de límite con la familia y el estilo de fuente especificados.

public static float GetMaximumEMSize(string text,
  Graphics graphics, FontFamily fontFamily, FontStyle fontStyle,
  float width, float height)
{
  const float MAX_ERROR = .25f;
  float curMin = 1.0f, curMax = width;
  float emSize = ((curMax - curMin) / 2f) + curMin;
  while(curMax - curMin > MAX_ERROR & curMin >= 1)
  {
    using (Font f = new Font(fontFamily, emSize, fontStyle))
    {
      SizeF size = graphics.MeasureString(text, f);
      bool textFits = size.Width < width & size.Height < height;
      if (textFits & emSize > curMin) curMin = emSize;
      else if (!textFits & emSize < curMax) curMax = emSize;
    }
    emSize = ((curMax - curMin) / 2f) + curMin;
  }
  return curMin;
}

Determino el tamaño máximo de la fuente mediante una búsqueda binaria. Asumo que el tamaño de fuente mínimo es un punto y que el tamaño de fuente máximo es el ancho del cuadro de límite. A continuación, el método empieza a comprobar si el texto en la fuente específica se ajusta al rectángulo de límite si se representa en un tamaño de punto intermedio. Si el texto representado se ajusta, GetMaximumEMSize prueba con un valor mayor entre dicho punto intermedio y el tamaño máximo. Si no se ajusta, el método prueba un valor entre el punto intermedio y el tamaño mínimo. Este enfoque de partir por la mitad continúa hasta que los tamaños máximos y mínimos actuales se encuentran a escasa distancia el uno del otro (un 0,25 del punto), momento en el que el método devuelve el mínimo como tamaño de fuente seleccionado.

Este método es muy útil, no sólo para representar el texto de felicitación sino para todos los textos que se representen en la aplicación. Cuando se cambia el tamaño del formulario, todos los controles de la aplicación que representa texto (como etiquetas y botones) vuelve a calcular el tamaño de la fuente basándose en el resultado de GetMaximumEMSize. Esto permite que las fuentes (los números de los botones de número, por ejemplo) se ajusten correctamente cuando cambia el tamaño del formulario.

La aplicación crea los sprites cuando se muestra el control WinningAnimation. El control toma una instantánea del aspecto del control PuzzleGrid en ese momento (ésta es la imagen que se representará bajo los sprites mientras giran y se desplazan). A continuación, crea 81 mapas de bits independientes del mapa de bits más grande y asigna cada uno a un nuevo sprite.

using(Bitmap bmp = new Bitmap(
  _grid.ClientRectangle.Width, _grid.ClientRectangle.Height))
{
  // Take a snapshot of the underlying puzzle grid
  using(Graphics g = Graphics.FromImage(bmp))
  {
    _grid.DrawToGraphics(g, _grid.ClientRectangle);
  }

  // Set up each individual sprite based on pulling out a section
  // of the underlying grid snapshot
  for(int i=0; i<_grid.State.GridSize; i++)
  {
    for(int j=0; j<_grid.State.GridSize; j++)
    {
      RectangleF cellRect = PuzzleGrid.GetCellRectangle(
        _grid.BoardRectangle, new Point(i,j));
      Bitmap smallImage = new Bitmap( (int)cellRect.Width,
        (int)cellRect.Height, PixelFormat.Format32bppPArgb);
      using(Graphics g = Graphics.FromImage(smallImage))
      {
        g.DrawImage(bmp, 0, 0, cellRect, GraphicsUnit.Pixel);
      }
      ImageSprite s = new ImageSprite(smallImage);
      ...
    }
  }
}

También creo un temporizador cuando se muestra el control. Cuando creo los sprites, cada uno se inicializa para estar en la posición correcta en el control, de manera que pueden superponerse sobre sus homólogos en la cuadrícula subyacente. Más tarde, se les asignan, de forma aleatoria, velocidades lineales, angulares y de volteo. A cada sprite se le asigna también aleatoriamente si debe rotar sobre su centro o sobre un ángulo. Cada vez que el temporizador se activa (24 veces por segundo, en función de la CPU), utilizo el método Update de cada sprite para cambiar su estado de forma adecuada (el método Update emplea varias velocidades para que el sprite actualice su información de posición) y el control se invalida a sí mismo, para volverse a dibujar.

for(int i=_sprites.Count-1; i>=0; --i)
{
  ImageSprite s = (ImageSprite)_sprites[i];
  s.Update();

  Rectangle bounds = ClientRectangle;
  if (s.Location.X > bounds.Right + s.Image.Width ||
    s.Location.X < -s.Image.Width ||
    s.Location.Y > bounds.Bottom + s.Image.Width ||
    s.Location.Y < -s.Image.Width)
  {
    _sprites.RemoveAt(i);
    s.Dispose();
  }
}
if (_sprites.Count == 0) _timer.Stop();
Invalidate();

Cuando un sprite sale del área visible del control, lo elimino de la colección de sprites. Cuando no hay más sprites para mostrar, detengo el temporizador. Detener el temporizador es importante para mejorar el rendimiento, pero también para los dispositivos móviles que deben preocuparse por el consumo de energía. Los temporizadores que se activan frecuentemente, por ejemplo, más de una vez por segundo, pueden impedir que el sistema entre en el modo de ahorro de energía. Por este motivo, si necesita usar un temporizador que se active frecuentemente (lo cual es bastante usual en los juegos y animaciones), procure desactivar los temporizadores cuando no necesite que estén en ejecución.

Almacenamiento de estado y configuración

El Sudoku utiliza hasta dos archivos en el almacén de datos de la aplicación local del usuario para mantener el estado de la aplicación. El juego utiliza un archivo para almacenar la configuración de la aplicación y el otro para almacenar el estado de un rompecabezas incompleto, para que el usuario pueda continuar la partida cuando vuelva a ejecutar el programa. La configuración y el estado se serializan y deserializan desde los archivos que usan BinaryFormatter disponible en el espacio de nombres System.Runtime.Serialization.Formatters.Binary.

El archivo de configuración contiene la información relacionada con las opciones que se han configurado (como mostrar los valores incorrectos, sugerir qué celda debe rellenarse a continuación, confirmar si se desea eliminar un rompecabezas en curso y crear nuevos rompecabezas con simetría), el tamaño y posición actuales del formulario principal y el nivel de dificultad del rompecabezas actual. Una vez omitido el control de excepciones para simplificar la presentación, la implementación de SaveSettings se efectúa del siguiente modo.

private void SaveSettings()
{
  BinaryFormatter formatter = new BinaryFormatter();
  string path = Environment.GetFolderPath(
    Environment.SpecialFolder.LocalApplicationData) +
    SAVED_SETTINGS_USER_PATH;

  DirectoryInfo parentDir = Directory.GetParent(path);
  if (!parentDir.Exists) parentDir.Create();

  ConfigurationOptions options =
    _optionsDialog.ConfiguredOptions;
  PuzzleDifficulty difficulty = _puzzleDifficulty;
  Rectangle windowBounds = GetRestoreBounds(this);
  bool maximized = (WindowState == FormWindowState.Maximized);
  using(Stream s = File.OpenWrite(path))
  {
    formatter.Serialize(s, options);
    formatter.Serialize(s, difficulty);
    formatter.Serialize(s, windowBounds);
    formatter.Serialize(s, maximized);
  }
}

El método LoadSettings realiza lo contrario, deserializa esta información en objetos del archivo de configuración y la restaura en el formulario y las opciones configuradas.

El método SaveStateFile, que se emplea para guardar el estado de un rompecabezas en curso, es el siguiente (de nuevo, he quitado el control de excepciones):

internal void SaveStateFile()
{
  if (thePuzzleGrid.PuzzleHasBeenModified)
  {
    string path = Environment.GetFolderPath(
      Environment.SpecialFolder.LocalApplicationData) +
      SAVED_STATE_USER_PATH;
    DirectoryInfo parentDir = Directory.GetParent(path);
    if (!parentDir.Exists) parentDir.Create();
    using(Stream s = File.OpenWrite(path))
    {
      BinaryFormatter formatter = new BinaryFormatter();
      formatter.Serialize(s,
        Assembly.GetEntryAssembly().GetName().Version);
      formatter.Serialize(s, thePuzzleGrid.State);
      formatter.Serialize(s, thePuzzleGrid.OriginalState);
      formatter.Serialize(s, thePuzzleGrid.UndoStates);
      formatter.Serialize(s, thePuzzleGrid.InkData);
    }
  }
}

Antes de realizar la serialización, el método SaveStateFile efectúa una comparación con PuzzleGrid para ver si el rompecabezas que está en curso se ha modificado respecto al original, pero no se ha resuelto todavía. Cuando SaveStateFile crea el archivo de estado, almacena cinco piezas de información. En primer lugar, guarda la versión actual de Sudoku, que es siempre una buena idea, en el caso de que en una futura versión decida cambiar la información que se va a almacenar en el archivo y necesite que sea compatible con archivos de estado anteriores de versiones más antiguas. Después, almacena el estado PuzzleState actual, seguido del PuzzleState original, que se proporciona al PuzzleGrid; esto último es necesario para saber cuáles de los números del rompecabezas actual son originales. A continuación, la pila de la función de deshacer se almacena en el archivo. Finalmente, se serializan todos los datos Ink de superposición de PuzzleGrid. La propiedad InkData de PuzzleGrid devuelve una matriz de bytes que contiene la tinta serializada.

_inkOverlay.Ink.Save(PersistenceFormat.InkSerializedFormat)

Como sucede con la configuración, el proceso de carga es, para todos los propósitos, el contrario al del método de almacenamiento. Este proceso carga cada dato del archivo y vuelve a restaurar la información en el PuzzleGrid.

Información sobre la carga de la batería

La alimentación es algo a tener muy en cuenta cuando se implementan aplicaciones móviles. Los usuarios deberían saber cuándo se va a quedar el dispositivo sin batería para que puedan tomar medidas y evitar la pérdida de datos. Cuando el Sudoku detecta que el nivel de batería es bajo o que el sistema va a entrar en modo de hibernación o de espera, guarda el rompecabezas actual. Esto lo hace en el formulario principal mediante el reemplazo del método WndProc (el método principal de un Control para el tratamiento de los mensajes de Windows), y de este modo guarda la partida actual cuando la aplicación recibe un mensaje WM_POWERBROADCAST (se puede obtener una funcionalidad similar con la clase Microsoft.Win32.SystemEvents).

protected override void WndProc(ref Message m)
{
  base.WndProc (ref m);

  const int WM_POWERBROADCAST = 0x218;
  const int PBT_APMSUSPEND = 0x0004;
  const int PBT_APMSTANDBY = 0x0005;
  const int PBT_APMBATTERYLOW = 0x0009;

  switch(m.Msg)
  {
    case WM_POWERBROADCAST:
      switch((int)m.WParam)
      {
        case PBT_APMBATTERYLOW:
        case PBT_APMSTANDBY:
        case PBT_APMSUSPEND:
           SaveStateFile();
           break;
      }
      break;
    ...
}

Así, en caso de que se pierda la sesión actual, el jugador podrá retomar la partida donde la dejó.

Además, con el reemplazo de WndProc, se puede realizar el tratamiento de otros mensajes no relacionados con la alimentación. Por ejemplo, anteriormente mencioné que llamo a SetHandedness para actualizar la IU y reflejar la configuración de lateralidad del usuario cuando se realiza algún cambio. Windows notifica a la aplicación el cambio en la configuración enviando un mensaje WM_SETTINGSCHANGE. Mi implementación WndProc comprueba si el cambio se ha realizado en el valor de configuración SPI_SETMENUDROPALIGNMENT. Si es así, llama a SetHandedness.

case WM_SETTINGSCHANGE:
  if (SPI_SETMENUDROPALIGNMENT == (int)m.WParam) SetHandedness();
  break;

Ampliación de Sudoku

Existen infinitas formas de ampliar y mejorar el código asociado al Sudoku. Aquí muestro algunas ideas y códigos que sirven como orientación.

Ampliación de la función de deshacer

Tal y como se ha implementado, la función de deshacer es muy útil, pero puede serlo aún más. De vez en cuando cometo errores cuando juego al Sudoku (increíble, ¿no?), hago una deducción incorrecta, no me doy cuenta de que el número que estoy escribiendo ya existe en la fila, columna o cuadro pertinentes, y otros fallos similares. En cuanto cometo el error, he perdido la partida, pero normalmente no me doy cuenta hasta varios movimientos más tarde y, llegados a ese punto, me da mucha pereza empezar a averiguar dónde me equivoqué. Más que la función de deshacer, necesito un método que me permita volver al punto donde me equivoqué inicialmente. Gracias a la funcionalidad de deshacer ya implementada y a la funcionalidad de Solver que he descrito anteriormente, su implementación es muy sencilla.

public void UndoUntilSolvableState()
{
  SolverOptions options = new SolverOptions();
  while(Solver.Solve(State, options).Status !=
PuzzleStatus.Solved & Undo());
}

Otra función muy útil es la posibilidad de rehacer. Puede agregar compatibilidad con la función de rehacer mediante otro PuzzleStateStack en el que se insertan estados a medida que se usa la función de deshacer. Anímese a intentarlo. Sólo recuerde que, a medida que rehace, deberá volver a insertar los estados en la pila de la función de deshacer.

Impresión de rompecabezas

Ampliar el Sudoku para que permita imprimir el rompecabezas actual no requiere prácticamente ningún esfuerzo. Por ejemplo, puede elegir una determinada tecla y ampliar el método OnKeyDown en PuzzleGrid para ejecutar el código cuando se presione la tecla.

using(PrintDialog pd = new PrintDialog())
using(PrintDocument doc = new PrintDocument())
{
  doc.PrintPage += new PrintPageEventHandler(HandlePrintPage);
  pd.Document = doc;
  if (pd.ShowDialog() == DialogResult.OK) doc.Print();
}

Así, podrá controlar el evento PrintPage con un código como el siguiente.

private void Document_PrintPage(object sender, PrintPageEventArgs e)
{
  DrawToGraphics(e.Graphics, Bounds);
  e.HasMorePages = false;
}

Así de simple. Por supuesto, el resultado no es perfecto. El tamaño del rompecabezas dependerá del tamaño actual de PuzzleGrid, no quedará centrado en la página y la impresión incluirá cosas como la celda sugerida actualmente. Esto se soluciona fácilmente creando un nuevo PuzzleGrid sólo para compatibilidad con la impresión.

private void Document_PrintPage(object sender, PrintPageEventArgs e)
{
  using(PuzzleGrid pg = new PuzzleGrid())
  {
    pg.State = State;
    pg.ShowSuggestedCells = false;
    pg.ShowIncorrectNumbers = false;
    if (e.MarginBounds.Width > e.MarginBounds.Height)
    {
      pg.Height = e.MarginBounds.Height;
      pg.Width = pg.Height;
    }
    else
    {
      pg.Width = e.MarginBounds.Width;
      pg.Height = pg.Width;
    }
    using(Matrix m = new Matrix())
    {
      GraphicsState state = e.Graphics.Save();
      m.Translate((e.PageBounds.Width - pg.Width) / 2,
            (e.PageBounds.Height - pg.Height) / 2);
      e.Graphics.Transform = m;
      pg.DrawToGraphics(e.Graphics,
        new Rectangle(0, 0, pg.Width, pg.Height));
      e.Graphics.Restore(state);
    }
  }
  e.HasMorePages = false;
}

Puede ir más allá e imprimir una colección completa de rompecabezas. Cada vez que se llame al controlador PrintPage, utilice la clase Generator para crear un PuzzleState completamente nuevo (en lugar de utilizar el PuzzleState de la cuadrícula actual, como en el ejemplo anterior) y, a continuación, utilice un código como éste para imprimirlo: Con ello, podrá imprimir tantos rompecabezas como desee, estableciendo HasMorePages en true hasta que haya terminado de imprimir el número de Sudokus deseado.

Compatibilidad con sistemas de múltiples procesadores

La generación de rompecabezas requiere un trabajo intensivo de la CPU. En sistemas con múltiples procesadores, es recomendable ampliar la infraestructura de generación de rompecabezas de Sudoku para aprovechar la ventaja de tener varios procesadores (la lógica actual para la generación del rompecabezas tiene un único subproceso). Agregar compatibilidad para ello es realmente mucho más sencillo de lo que parece porque cada rompecabezas que se muestra al usuario es el resultado de varios rompecabezas generados, entre los que se elige el más difícil. Actualmente, esto se realiza en un bucle secuencial.

private PuzzleState GenerateInternal()
{
  ArrayList puzzles = new ArrayList();
  for(int i=0; i<_options.NumberOfPuzzles; i++)
  {
    puzzles.Add(GenerateOne());
  }
  puzzles.Sort(new SolverResultsComparer(_options.Techniques));
  return ((SolverResults)puzzles[puzzles.Count-1]).Puzzle;
}

Modifíquelo para que las llamadas a GenerateOne se envíen a una cola de ThreadPool y tendrá compatibilidad instantánea para la generación simultánea de rompecabezas. Por supuesto, realizar este cambio requiere alguna sincronización adicional, ya que deberá posponer la llamada a puzzles.Sort hasta que los subprocesos que se ejecutan en segundo plano terminen de generar sus rompecabezas y los guarden de forma segura en la colección (cuyo acceso debería sincronizarse también). Una manera de hacerlo es utilizar algo parecido a la clase ThreadPoolWait que describí en la columna .NET Matters de octubre de 2004 (en inglés) de MSDN Magazine.

Cómo copiar el rompecabezas en el portapapeles

Es posible que quiera guardar una copia del rompecabezas actual. Una forma sencilla de hacerlo es copiar una representación textual en el portapapeles. La compatibilidad para esta acción ya está creada en PuzzleState, ya que el reemplazo de ToString devuelve una representación textual del rompecabezas, lo único que necesita es copiar la cadena en el portapapeles. Para ello, sólo tiene que agregar algún fragmento de código al reemplazo de OnKeyDown en MainForm.

if (e.KeyCode == Keys.C & e.Control &
  thePuzzleGrid.Visible & thePuzzleGrid.Enabled)
{
  if (!thePuzzleGrid.CollectingInk)
  {
    string text = thePuzzleGrid.State.ToString();
    try { Clipboard.SetDataObject(text, true); }
    catch(ExternalException){ }
  }
}

Además...

A continuación, incluyo algunas sugerencias de tareas que se pueden realizar.

  • Implementar otros algoritmos para resolver o generar rompecabezas.

  • Implementar sugerencias opcionales adicionales para el jugador o modificar la "sugerencia de celda" actual para explicar por qué se sugiere dicha celda.

  • Crear compatibilidad con otros movimientos del lápiz.

  • Permitir cuadrículas de rompecabezas de un tamaño mayor que 9x9.

  • Agregar una interfaz de usuario de Windows Presentation Foundation (WPF).

  • Utilizar Windows Communication Foundation (WCF) para permitir la resolución de rompecabezas en colaboración a través de una red.

  • Crear una interfaz de Media Center Markup Language (MCML) para Windows Media Center en Windows Vista. (Tenga en cuenta que, después de iniciarla, mi implementación de Sudoku permite un manejo sencillo mediante las teclas de dirección para desplazarse y las numéricas para escribir los valores.)

  • Agregar más funciones relacionadas con el juego, como un registro de las puntuaciones más altas y un temporizador de juego.

Las posibilidades son muchas y estoy deseando ver los aportes. Que disfrute de la codificación.

Agradecimientos

Le doy mis más sinceras gracias al equipo de Tablet PC por su apoyo en este proyecto; a David Bessenhoffer por sus fantásticas imágenes; a Calvin Hsia y Brian Goldfarb por sus ideas y sugerencias; a John Keefe y Luke Stein por presentarme este juego tan fascinante; a Eliot Graff por su ayuda en la publicación del artículo y el código; y a Tamara Spiewak por su apoyo y cariño (hacia mí y hacia el Sudoku).

Biografía

Stephen Toub es jefe técnico del equipo de MSDN en Microsoft. Es el editor técnico de la revista MSDN Magazine, para la que también escribe la columna .NET Matters. Stephen desarrolló la implementación de Sudoku que se incluye en Touch Pack, un paquete de software que se suministra con todos los equipos PC ultra móviles.

Mostrar: